Designing for Testability in Spring (Controller → Handler → Service)
When building applications with Spring Boot/WebFlux, testability often comes down to code structure and design choices. Below are recommendations that balance clean design, maintainability, and ease of testing.
1. Keep Layers Simple and Focused
- Controller → Handler → Service → Repository is a good separation.
- Each layer has one responsibility:
- Controller: request/response mapping, validation.
- Handler: orchestrate workflows, call multiple services.
- Service: business logic, domain rules, persistence.
- Tests then focus on each layer separately:
- Controllers with slice tests.
- Services with unit tests.
- End-to-end wiring with integration tests.
2. Favor Dependency Injection with Interfaces
- Define contracts (
UserService,OrderRepository) as interfaces. - Inject implementations into higher layers.
- Tests can swap real beans for mocks (
@MockBean) or fakes easily.
3. Design for Composition, Not Inheritance
- Prefer smaller, composable handlers/services over large, monolithic ones.
- Compose workflows instead of deep inheritance trees.
- Makes it easier to test small parts in isolation.
4. Avoid Static State and Hard Coupling
- Static utils that talk to DB, files, or environment are hard to test.
- Wrap them in injectable services (
TimeProvider,IdGenerator). - Tests can provide predictable mocks.
5. Use Test Doubles Wisely
- Mocks: verify interactions (
verify(service).save(..)). - Stubs/Fakes: return canned values, simpler than mocks.
- Prefer fakes when possible (less brittle).
6. Keep Business Logic out of Controllers
- Controllers should delegate.
- Real rules live in services → tested without HTTP layer.
- Controller tests then only check mapping/validation.
7. Adopt Pragmatic Patterns
- Strategy/Registry: for pluggable behaviors (document handlers, etc.).
- Command/Query handlers: if domain complexity justifies.
- Keep patterns shallow—avoid factories of factories.
8. Testing Pyramid
- Unit tests: fast, isolated, no Spring context.
- Slice tests (
@WebFluxTest,@DataMongoTest): cover one layer + mocks. - Integration tests (
@SpringBootTest+ Testcontainers): realistic, minimal mocks. - E2E tests: real HTTP + DB, no mocks.
9. Refactor for Testability
If mocking feels painful:
- The class might have too many responsibilities.
- Dependencies may be hidden.
- Split big classes and expose contracts.
10. Balance Complexity vs Pragmatism
- Avoid over-engineering (heavy DDD, CQRS) unless justified.
- Don’t abstract everything “just in case”.
- Optimize for clarity and predictability.
✅ Takeaway
The best structure for testability:
- Thin, focused layers.
- Clear contracts (interfaces).
- Push logic into services.
- Controllers delegate.
- Use stubs/fakes for speed, mocks for precision.
- Apply patterns only when they reduce duplication.