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
BookingService

Benefits:

  • 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

See: Service classes approach

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:

  1. Put it behind a load balancer
  2. Spin up multiple replicas
  3. 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:

  1. Consistency: Implement the Outbox Pattern to maintain data consistency across boundaries
  2. Resiliency: Add circuit breakers, retries, timeouts, fallbacks
  3. Idempotency: Ensure all operations are idempotent across service boundaries
  4. Observability: Distributed tracing becomes mandatory
  5. 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

  1. Unit Tests: Pure business logic in the core

  2. Integration Tests: Application services with stubbed repositories

    • Validate process orchestration
    • Stub repository to control scenarios
    • No database needed
  3. 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:

Simple Design

Kent Beck’s rules in priority order:

  1. Runs all the tests
  2. No duplication
  3. Reveals all the intention
  4. 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”

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.”