A sensible default architecture provides a solid starting point for most applications without over-engineering. This approach combines proven patterns to create maintainable, testable systems.
Start with a Modular Monolith
The default starting point is a Modular Monolith - a single deployable application organized into clear, bounded modules.
The Boundary Incubator
The modular monolith serves as a boundary incubator where you discover and stabilize your system’s boundaries:
- Refactoring inside a monolith: An IDE shortcut
- Refactoring between services: A cross-team nightmare
Don’t split until your boundaries are stable. You need time to learn where the natural seams are in your domain. A monolith gives you that flexibility.
Module Organization
Each module should have:
- Clear boundaries (bounded contexts)
- Its own database schema/tables
- Minimal coupling to other modules
- Ability to be extracted later if needed
This makes future extraction possible without requiring it upfront.
Core Architectural Pattern
Functional Core, Imperative Shell
The foundation is the Functional Core, Imperative Shell pattern:
- Core: Pure business logic with no side effects
- Shell: Handles all I/O, external systems, and side effects
This separation makes the bulk of your code easy to test and reason about.
Structural Organization
Start with Vertical Slices
Organize code by features rather than technical layers using Vertical Slice Architecture:
src/
├─ features/
│ ├─ create-booking/
│ │ ├─ CreateBookingRequest.cs
│ │ ├─ CreateBookingHandler.cs
│ │ └─ CreateBookingValidator.cs
│ └─ get-booking/
│ ├─ GetBookingRequest.cs
│ └─ GetBookingHandler.cs
Why?
- Related code lives together
- Easy to find everything for a feature
- Teams can own complete features
- Deleting a feature means deleting a folder
Internal Structure: Hexagonal Architecture Has Won
For the internal structure of your modular monolith, the Hexagonal Architecture (Ports & Adapters) approach has become the gold standard:
- Define ports (interfaces) for external dependencies
- Implement adapters for databases, APIs, etc.
- Keep business logic independent of infrastructure
When domain logic is complex, layer on Clean Architecture and Domain-Driven Design to keep the system maintainable:
- Use entities and value objects for rich domain models
- Apply use cases to orchestrate business processes
- Enforce the dependency rule (dependencies point inward)
Start lightweight - add these patterns as complexity emerges, not preemptively.
Application Services (Use Cases)
Create focused service classes that orchestrate one process:
// Good: Single responsibility
CreateBookingService
CancelBookingService
UpdateBookingService
// Avoid: Becomes a dumping ground
BookingServiceBenefits:
- Clear ownership of each business process
- Easy to test without controller concerns
- Aligns with how users think about the system
- Maps directly to user stories
Persistence Layer
Repository Pattern
Use the repository pattern to decouple persistence:
interface IBookingRepository
{
Booking Get(BookingId id);
void Save(Booking booking);
}Why?
- Test business logic without database
- Swap implementations (SQL, NoSQL, in-memory)
- Focus tests on behavior, not data access
Keep repositories simple - resist the urge to add query methods for every scenario. Consider separate query objects for complex reads (CQRS-lite).
The Boring Infrastructure
Default to boring, proven technologies that solve 90% of problems:
Database: PostgreSQL
- Handles almost everything: relational data, JSON, full-text search, geospatial
- Battle-tested, well-understood, abundant expertise
- Horizontal scaling options when needed (read replicas, sharding)
Caching: Redis
- De-facto standard for distributed caching
- Also handles sessions, rate limiting, pub/sub
- Simple, fast, reliable
Observability: OpenTelemetry (OTEL)
- Unified standard for logs, metrics, and traces
- Vendor-agnostic instrumentation
- Future-proof as ecosystem matures
Why boring? Exotic tech choices add cognitive load, hiring difficulty, and operational complexity. Use them only when you have a specific, compelling reason.
Scalability Strategy
Step 1: Scale Horizontally First
Before splitting anything, scale the monolith horizontally:
- Put it behind a load balancer
- Spin up multiple replicas
- Let it run
Benefits:
- Easier and cheaper than distribution
- Keeps data consistency simple
- No network latency between components
- No distributed transaction headaches
Step 2: Extract as Last Resort
Only carve out a service if it has:
- Unique resource demands (high CPU, GPU, memory)
- Different tech stack requirements (specialized libraries, runtimes)
- Independent scaling needs (10x more traffic than rest of system)
- Team autonomy requirements (see Microservices below)
The Distribution Tax
The moment you extract a service, you pay the distribution tax:
- Consistency: Implement the Outbox Pattern to maintain data consistency across boundaries
- Resiliency: Add circuit breakers, retries, timeouts, fallbacks
- Idempotency: Ensure all operations are idempotent across service boundaries
- Observability: Distributed tracing becomes mandatory
- Testing: Integration testing becomes significantly more complex
Don’t pay this tax until you must.
Microservices as Organizational Tool (Conway’s Law)
Microservices are primarily a solution to human scaling, not technical performance.
When Teams Are the Problem
Extract services when:
- Multiple teams stepping on each other in the same codebase
- Different teams need different deployment cadences
- Team autonomy is being blocked by shared codebase coordination
This is Conway’s Law in action: your architecture mirrors your organizational structure.
The Trade-off
You gain:
- Team independence
- Separate deployment pipelines
- Clear ownership boundaries
- Reduced coordination overhead
You pay:
- Technical complexity (distribution tax)
- Operational overhead (more deployables)
- Cross-service debugging difficulty
- Network reliability concerns
Start with a modular monolith. If you structure it well, extracting services later is straightforward. Going the other direction (consolidating microservices) is a nightmare.
Testing Strategy
Test Pyramid
-
Unit Tests: Pure business logic in the core
- Fast, no external dependencies
- Use real objects, not mocks when possible
- See: Favor real dependencies
-
Integration Tests: Application services with stubbed repositories
- Validate process orchestration
- Stub repository to control scenarios
- No database needed
-
End-to-End Tests: Critical paths only
- Real database, real dependencies
- Few but high-value
- Focus on happy paths and critical failures
Design Principles
Follow these principles to keep the architecture sound:
- SOLID Principles
- Tell, Don’t Ask
- YAGNI - Don’t build what you don’t need
- DRY - But prefer duplication over wrong abstraction
Simple Design
Kent Beck’s rules in priority order:
- Runs all the tests
- No duplication
- Reveals all the intention
- Fewest number of classes or methods
See: Simple design
Evolution Path
Phase 1: Simple Modular Monolith
Start here:
- Feature folders (vertical slices)
- Focused use case classes
- Basic repository interfaces
- Single database, single deployment
- Boring tech stack (PostgreSQL, Redis, OTEL)
Phase 2: Add Internal Structure
Add when complexity demands:
- Hexagonal architecture ports/adapters
- Clean Architecture layers
- DDD patterns (entities, value objects, aggregates)
- Explicit module boundaries
Phase 3: Horizontal Scaling
Before considering distribution:
- Load balancer + multiple replicas
- Read replicas for database
- Cache layer (Redis)
- CDN for static assets
Phase 4: Selective Extraction
Only when necessary:
- Specific module has unique resource needs
- Team autonomy blocked by monolith coordination
- Different tech stack required for specific capability
Be prepared to pay the distribution tax.
What NOT to Do
Don’t preemptively:
- Split into microservices “for scalability”
- Build abstractions for hypothetical needs
- Create frameworks within your application
- Add layers “for future flexibility”
- Choose exotic tech “because it’s interesting”
Related Concepts
- Architectural Patterns
- Hexagonal Architecture
- Clean Architecture
- Vertical Slice Architecture
- Domain Driven Design
- Software Design Principles
Key Takeaways
Start with a Modular Monolith
The sensible default is a well-structured modular monolith, not microservices. Use it as a boundary incubator to discover where your system’s natural seams are.
Scale Horizontally Before Distributing
Throw more replicas at the problem before splitting services. It’s cheaper, simpler, and keeps consistency trivial.
Microservices Are Organizational, Not Technical
Extract services to solve team coordination problems, not because you think you need to scale. Conway’s Law means your architecture will mirror your org structure anyway.
Boring Tech Wins
PostgreSQL, Redis, and OpenTelemetry handle 90% of use cases. Choose exotic tech only when boring tech genuinely can’t solve the problem.
Pay the Distribution Tax Knowingly
Distributed systems require outbox patterns, circuit breakers, idempotency, and complex testing. Don’t pay this tax until you must.
Let Complexity Emerge
Add structure (hexagonal architecture, DDD, clean architecture) only when complexity demands it, not because it feels “professional” or “scalable.”