Skip to main content

Chapters 27–29: Main Component, Services & The Test Boundary

Chapter 27: The Main Component

"Main is the dirtiest of all the dirty components."

What Is Main?

Main is the entry point — the @SpringBootApplication class, the public static void main() method. It is the only component that is allowed to know about all the concrete classes in the system.

Main creates concrete objects and wires them together. Every new ConcreteClass() call in the application lives here (or in Spring's @Configuration classes). From every other class's perspective, all dependencies arrive as interfaces via injection.

Main as a Plugin

Think of Main as the ultimate plugin — the one that configures all other plugins and starts the machine. It is the concrete factory for the entire system.

Because Main is in the outermost ring, it can be replaced completely. You can have:

  • A production Main that wires real repositories, real payment gateways, real message brokers
  • A test Main (Spring Boot test context) that wires test doubles
  • A development Main that wires H2 in-memory database and mock external services

The business logic — use cases, entities — never changes across these configurations.

// Production: real implementations wired by Spring
@SpringBootApplication
@Import(ProductionInfrastructureConfig.class)
public class ProductionMain { ... }

// Integration test: test containers + real Spring context
@SpringBootTest
@Import(TestInfrastructureConfig.class)
class IntegrationTest { ... }

// Unit test: pure mocks, no Spring
class UseCaseTest {
// New the use case manually with mocks
var useCase = new PlaceOrderUseCase(mockRepo, mockPublisher);
}

Chapter 28: Services — Great and Small

"Service-oriented 'architectures' and micro-service 'architectures' have become very popular recently... But are services, in and of themselves, an architecture?"

Martin's Provocative Answer: No

Services are not architecture. They are a deployment and communication mechanism. A system of poorly structured services is no better architecturally than a poorly structured monolith — it's just more expensive to operate.

The crucial insight:

The architecture of a system is defined by the boundaries drawn within it — not by the deployment units that contain those boundaries.

A service is just one option for where to draw a physical boundary. The architectural benefit of services — independent deployability — only materializes if the logical boundaries inside the service are also clean.

The Two Fallacies of Microservices

Fallacy 1: Services are inherently decoupled.

A service that shares a database table with another service is coupled to that service regardless of being in a separate process. A service that publishes events in a format that five consumers depend on is coupled to all five consumers. Physical separation does not produce logical decoupling.

Fallacy 2: Services define clear boundaries.

Services can have muddy internal structure. A microservice that's a "big ball of mud" inside its process boundary gives you all the operational cost of microservices with none of the architectural benefit.

The "Kitty" Problem

Martin uses a fun example: a rideshare app initially has services for Rides, Drivers, Finance, Notifications. A new requirement: "Kitty rides" (ride-sharing for cats).

This feature cuts across ALL services:

  • Rides: needs kitty-compatible vehicle logic
  • Drivers: needs kitty-certified driver flag
  • Finance: needs kitty pricing tier
  • Notifications: needs meow-translated messages

Every service must change. The "decoupled" services are actually coupled through this cross-cutting feature. This is the cross-cutting concern problem — and it exists whether your boundary is a service, a package, or a layer.

// The "Kitty" cross-cutting concern in microservices
RideService.java → add kittyCompatible flag
DriverService.java → add kittyCertified flag
FinanceService.java → add KITTY pricing tier
NotificationService.java → add kitty message templates
// 4 services to deploy, 4 teams to coordinate

Good architecture deals with cross-cutting concerns explicitly — through component structure, not by hoping service boundaries will contain them.


Chapter 29: The Test Boundary

"Tests are not outside the system; they are part of it."

Tests As a Component

Tests are a system component that lives in the outermost ring — even further out than the UI or database. Tests depend on the system but nothing depends on tests.

The dependency rule applies: tests can import anything from the inner rings. But the inner rings must never depend on tests.

The Fragile Test Problem

When tests couple directly to implementation details (not interfaces), they become:

  • Fragile: a refactoring that doesn't change behavior breaks dozens of tests
  • Costly to change: test code becomes a second codebase to maintain in parallel
  • Discouraging: developers stop refactoring to avoid breaking tests
// Fragile: test coupled to implementation detail
@Test void orderHasCorrectTotal() {
Order order = new Order();
order.items = new ArrayList<>(); // accessing private field via reflection or package-private
order.items.add(new LineItem("SKU-1", 2, BigDecimal.valueOf(10.00)));
order.status = OrderStatus.PLACED; // setting internal state directly
assertEquals(BigDecimal.valueOf(20.00), order.getTotal());
}

// Robust: test coupled to public behavior only
@Test void orderTotalIsCalculatedFromItems() {
Order order = Order.create(customerId, List.of(
LineItem.of("SKU-1", 2, Money.of(10.00, USD))
));
assertThat(order.getTotal()).isEqualTo(Money.of(20.00, USD));
}

The Testing API

Martin proposes a Testing API — a set of interfaces and utilities designed specifically for testing, that allow tests to verify behavior without coupling to structure:

  • Test-specific subclasses of domain objects that expose otherwise private state
  • In-memory implementations of repositories and gateways (not mocks — full implementations with real behavior)
  • Builders and fixtures that construct valid domain state declaratively
// Testing API: in-memory repository (not a mock — real behavior)
public class InMemoryOrderRepository implements OrderRepository {
private final Map<OrderId, Order> store = new HashMap<>();

@Override
public Optional<Order> findById(OrderId id) {
return Optional.ofNullable(store.get(id));
}

@Override
public void save(Order order) {
store.put(order.getId(), order);
}

// Testing-specific convenience
public List<Order> all() { return List.copyOf(store.values()); }
public boolean isEmpty() { return store.isEmpty(); }
}

This in-memory repository can be shared across all use case tests, provides full repository behavior, and eliminates the need for Mockito mocking of complex query behavior.


🔬 Senior Deep Dive

Main and Spring Boot Auto-Configuration

Spring Boot's auto-configuration is Main's mechanism for wiring the system. Each @AutoConfiguration class is a factory for a slice of the system. The @Conditional annotations control which factories activate.

This is Clean Architecture's "concrete component" realized in framework code: all concretion lives in auto-configuration classes; your application code depends only on interfaces.

Extending this: define your own @AutoConfiguration for your internal libraries. Components that need testing can exclude them with @TestConfiguration — swapping one "Main" configuration for another.

Service Architecture Patterns

When services are the right boundary, Martin's advice is to still apply the dependency rule within each service:

microservice: order-service
├── domain/ ← entities (same as monolith)
├── application/ ← use cases (same as monolith)
├── adapter/ ← REST controllers, JPA repos (same as monolith)
└── main/ ← Spring Boot main (same as monolith)

A microservice that applies Clean Architecture internally is a "Clean Microservice." Its business logic is protected from infrastructure choices in exactly the same way as in a monolith.

The only difference: the persistence adapter might call a REST API instead of a local database, and the controller adapter publishes to Kafka instead of returning HTTP directly. The use cases are identical.

The Test Double Hierarchy

Martin's testing API principle aligns with the test double hierarchy:

TypeReplacesBehaviorBest For
DummyA parameterDoes nothingFilling parameter lists
StubA collaboratorReturns canned responsesControlling flow in unit tests
SpyA collaboratorRecords interactionsVerifying method calls
MockA collaboratorExpectation-basedBehavioral verification
FakeA whole componentReal logic, simplifiedIn-memory repos, test DBs

Martin favors Fakes (in-memory implementations) over Mocks for repositories and gateways — fakes provide more realistic behavior and are less fragile than mock expectations.


Summary

ChapterKey Point
27: Main ComponentMain is the "dirtiest" class — wires everything; every other class is clean
28: ServicesServices ≠ architecture; poorly structured services = costly monolith
28: Service fallaciesShared DB = coupled; cross-cutting features span services regardless
29: Test BoundaryTests are a component; couple to behavior not structure
29: Testing APIFakes (in-memory implementations) > Mocks for complex collaborators