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
Mainthat wires real repositories, real payment gateways, real message brokers - A test
Main(Spring Boot test context) that wires test doubles - A development
Mainthat 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 logicDrivers: needs kitty-certified driver flagFinance: needs kitty pricing tierNotifications: 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:
| Type | Replaces | Behavior | Best For |
|---|---|---|---|
| Dummy | A parameter | Does nothing | Filling parameter lists |
| Stub | A collaborator | Returns canned responses | Controlling flow in unit tests |
| Spy | A collaborator | Records interactions | Verifying method calls |
| Mock | A collaborator | Expectation-based | Behavioral verification |
| Fake | A whole component | Real logic, simplified | In-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
| Chapter | Key Point |
|---|---|
| 27: Main Component | Main is the "dirtiest" class — wires everything; every other class is clean |
| 28: Services | Services ≠ architecture; poorly structured services = costly monolith |
| 28: Service fallacies | Shared DB = coupled; cross-cutting features span services regardless |
| 29: Test Boundary | Tests are a component; couple to behavior not structure |
| 29: Testing API | Fakes (in-memory implementations) > Mocks for complex collaborators |