Back to blog

Layered (N-Tier) Architecture: Separation of Concerns in Practice

software-architecturebackenddesign-patternsspring-bootsystem-design
Layered (N-Tier) Architecture: Separation of Concerns in Practice

Layered architecture is arguably the most widely used architectural pattern in software development. If you've built a Spring Boot app with controllers, services, and repositories — you've already used it. If you've built an Express.js app with routes, middleware, and database calls — same story.

But using a pattern and understanding it deeply are different things. A poorly applied layered architecture leads to leaky abstractions, anemic domain models, and code that's technically layered but structurally a mess.

In this post, we'll cover:

✅ What layered architecture is and why it exists
✅ The standard layers and their responsibilities
✅ Strict vs relaxed layering — the trade-offs
✅ How dependency direction works (and why it matters)
✅ N-tier deployment topologies
✅ Practical implementation in Spring Boot and Express.js
✅ Common mistakes: anemic domain models, leaky abstractions
✅ When layered architecture breaks down


What Is Layered Architecture?

Layered architecture organizes code into horizontal layers, where each layer has a specific responsibility and communicates only with adjacent layers.

The core idea: each layer only talks to the layer directly below it. The presentation layer doesn't know how data is stored. The data access layer doesn't know what the UI looks like.

This separation of concerns is the foundation of clean, testable, maintainable code.

Why Layered Architecture?

Before layered architecture, codebases were often a tangle of SQL queries mixed into UI rendering code, business logic scattered across view templates, and zero separation between concerns.

Layered architecture brought discipline:

  • Separation of concerns — each layer has one job
  • Testability — mock the layer below and test each layer in isolation
  • Team organization — frontend devs work on presentation, backend devs on business logic, DBA on data access
  • Replaceability — swap out the database without touching business logic (in theory)

The Standard Layers

Layer 1: Presentation Layer

Also called: UI layer, web layer, interface layer, controller layer

Responsibility: Accept incoming requests, validate input, delegate to the business layer, and format the response.

Does NOT:

  • Contain business logic
  • Call the database directly
  • Know about how data is stored
// Spring Boot — Presentation Layer
@RestController
@RequestMapping("/api/orders")
public class OrderController {
 
    private final OrderService orderService;
 
    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }
 
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
 
        // 1. Validate input (handled by @Valid)
        // 2. Delegate to service — NO business logic here
        Order order = orderService.createOrder(request.toCommand());
 
        // 3. Format response
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(OrderResponse.from(order));
    }
 
    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
        return orderService.findById(id)
                .map(order -> ResponseEntity.ok(OrderResponse.from(order)))
                .orElse(ResponseEntity.notFound().build());
    }
}
// Express.js — Presentation Layer
const orderRouter = Router();
 
orderRouter.post('/', validateRequest(createOrderSchema), async (req, res) => {
  // Delegate to service — no business logic
  const order = await orderService.createOrder(req.body);
  res.status(201).json(OrderResponse.from(order));
});
 
orderRouter.get('/:id', async (req, res) => {
  const order = await orderService.findById(req.params.id);
  if (!order) return res.status(404).json({ error: 'Order not found' });
  res.json(OrderResponse.from(order));
});

Layer 2: Business Logic Layer

Also called: Service layer, application layer, domain layer

Responsibility: Implement the application's business rules. Orchestrate operations across multiple repositories. Enforce invariants and constraints.

Does NOT:

  • Know about HTTP, REST, or how it's called
  • Directly access the database (uses repository interfaces)
  • Know about response formatting
// Spring Boot — Business Logic Layer
@Service
@Transactional
public class OrderService {
 
    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;
    private final UserRepository userRepository;
    private final EventPublisher eventPublisher;
 
    public Order createOrder(CreateOrderCommand command) {
        // Business rule: user must exist
        User user = userRepository.findById(command.getUserId())
                .orElseThrow(() -> new UserNotFoundException(command.getUserId()));
 
        // Business rule: validate all products exist and are in stock
        List<OrderItem> items = command.getItems().stream()
                .map(item -> {
                    Product product = productRepository.findById(item.getProductId())
                            .orElseThrow(() -> new ProductNotFoundException(item.getProductId()));
 
                    if (product.getStock() < item.getQuantity()) {
                        throw new InsufficientStockException(product.getId());
                    }
 
                    return new OrderItem(product, item.getQuantity());
                })
                .collect(toList());
 
        // Create and persist the order
        Order order = Order.create(user, items);
        Order savedOrder = orderRepository.save(order);
 
        // Publish domain event (notify other parts of the system)
        eventPublisher.publish(new OrderCreatedEvent(savedOrder));
 
        return savedOrder;
    }
}
// Express.js — Business Logic Layer
export class OrderService {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly productRepository: ProductRepository,
    private readonly userRepository: UserRepository,
  ) {}
 
  async createOrder(command: CreateOrderCommand): Promise<Order> {
    const user = await this.userRepository.findById(command.userId);
    if (!user) throw new UserNotFoundException(command.userId);
 
    const items = await Promise.all(
      command.items.map(async (item) => {
        const product = await this.productRepository.findById(item.productId);
        if (!product) throw new ProductNotFoundException(item.productId);
        if (product.stock < item.quantity) throw new InsufficientStockException(item.productId);
        return new OrderItem(product, item.quantity);
      }),
    );
 
    const order = Order.create(user, items);
    return this.orderRepository.save(order);
  }
}

Layer 3: Data Access Layer

Also called: Repository layer, persistence layer, DAO layer

Responsibility: Abstract the database. Translate between domain objects and database rows/documents. Execute queries.

Does NOT:

  • Contain business logic
  • Know about HTTP or the presentation layer
  • Make decisions about what data to fetch (that's the service's job)
// Spring Boot — Data Access Layer
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    // Spring Data JPA generates query from method name
    List<Order> findByUserIdOrderByCreatedAtDesc(Long userId);
 
    // Custom JPQL query
    @Query("SELECT o FROM Order o WHERE o.status = :status AND o.createdAt > :since")
    List<Order> findRecentByStatus(
            @Param("status") OrderStatus status,
            @Param("since") LocalDateTime since);
}
// Express.js — Data Access Layer (using TypeORM)
@Injectable()
export class OrderRepository {
  constructor(
    @InjectRepository(OrderEntity)
    private readonly repo: Repository<OrderEntity>,
  ) {}
 
  async findByUserId(userId: string): Promise<Order[]> {
    const entities = await this.repo.find({
      where: { userId },
      order: { createdAt: 'DESC' },
      relations: ['items', 'items.product'],
    });
    return entities.map(Order.fromEntity);
  }
 
  async save(order: Order): Promise<Order> {
    const entity = order.toEntity();
    const saved = await this.repo.save(entity);
    return Order.fromEntity(saved);
  }
}

Layer 4: Database Layer

Not really a code layer — this is the actual database: PostgreSQL, MySQL, MongoDB, etc.

In 3-tier architectures, this is often a separate physical tier (a separate server). In development, it's typically the same machine.


Strict vs Relaxed Layering

A critical decision when adopting layered architecture: how strictly do you enforce the "only talk to the adjacent layer" rule?

Strict Layering

Each layer can only call the layer directly below it.

Pros: Clean architecture, easy to reason about dependencies, maximum replaceability.

Cons: Sometimes forces you to write "pass-through" methods in the service layer that just call the repository with no real business logic — boilerplate that adds no value.

Relaxed Layering

Layers can call any layer below them (not just the adjacent one).

Pros: Eliminates pass-through boilerplate for simple CRUD operations.

Cons: The presentation layer starts bypassing business logic, leading to duplicate validation and scattered business rules.

Which to Use?

Default to strict layering. The apparent boilerplate of pass-through service methods is a trade-off worth making — it keeps business logic centralized, testable, and easy to evolve.

The one exception: simple CRUD operations where the service method would genuinely be a pass-through with zero logic. In those cases, relaxed layering saves time without meaningful downsides.


Dependency Direction

Understanding dependency direction is critical for maintainable layered architecture. The rule:

Higher layers depend on lower layers. Lower layers never depend on higher layers.

This means:

  • The OrderController knows about OrderService — fine.
  • The OrderService knows about OrderRepository — fine.
  • The OrderRepository calling OrderServicenever. This would create a circular dependency.
  • The OrderService knowing about HTTP response codes — never. That's presentation layer knowledge leaking into business logic.

Dependency Inversion for Layer Decoupling

The most powerful tool for clean layered architecture: depend on abstractions (interfaces), not concrete implementations.

// ✅ Business layer depends on an interface (abstraction)
@Service
public class OrderService {
    private final OrderRepository orderRepository;  // Interface, not concrete class
 
    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
}
 
// Data access layer provides the concrete implementation
@Repository
public class JpaOrderRepository implements OrderRepository {
    // ... JPA-specific implementation
}
 
// For testing, inject a mock implementation
@Mock
OrderRepository mockOrderRepository;

Now you can:

  • Test OrderService without a real database (inject a mock)
  • Swap JpaOrderRepository for MongoOrderRepository without changing OrderService
  • Replace Spring Data with raw JDBC without touching business logic

N-Tier Deployment Topologies

"N-tier" refers to the physical deployment of layers across separate machines — not just logical code organization.

2-Tier Architecture

The application and database on separate machines. Most web apps start here:

Use when: Simple applications, small teams, straightforward requirements.

3-Tier Architecture

Separate physical tiers for presentation, application, and data:

Use when: High-traffic applications needing independent scaling of web and application tiers.

N-Tier with Caching and Messaging

Use when: Production systems with high traffic, caching requirements, and background processing.


Practical Implementation: Spring Boot

A full Spring Boot example showing clean layer separation:

src/main/java/com/example/store/
├── presentation/
│   ├── controller/
│   │   ├── OrderController.java
│   │   └── ProductController.java
│   └── dto/
│       ├── CreateOrderRequest.java
│       ├── OrderResponse.java
│       └── ProductResponse.java
├── business/
│   ├── service/
│   │   ├── OrderService.java
│   │   └── ProductService.java
│   └── domain/
│       ├── Order.java
│       ├── OrderItem.java
│       └── Product.java
├── data/
│   ├── repository/
│   │   ├── OrderRepository.java        # Interface
│   │   └── ProductRepository.java      # Interface
│   └── jpa/
│       ├── JpaOrderRepository.java     # Spring Data implementation
│       ├── JpaProductRepository.java
│       ├── OrderEntity.java
│       └── ProductEntity.java
└── StoreApplication.java

Key design decisions in this structure:

  1. DTOs vs Domain Objects — The presentation layer uses DTOs (OrderResponse). The business layer uses domain objects (Order). The data layer uses entities (OrderEntity). They're separate classes that get mapped.

  2. Repository interfaces in business layer — The OrderRepository interface lives in the business layer, not the data layer. The data layer provides implementations. This is dependency inversion in practice.

  3. No JPA annotations in domain objects — Domain objects (Order.java) are plain Java classes, not JPA entities. This keeps business logic free of persistence concerns.


Practical Implementation: Express.js / NestJS

src/
├── modules/
│   └── orders/
│       ├── presentation/
│       │   ├── orders.controller.ts
│       │   ├── dto/
│       │   │   ├── create-order.dto.ts
│       │   │   └── order-response.dto.ts
│       │   └── orders.module.ts
│       ├── business/
│       │   ├── orders.service.ts
│       │   ├── order.entity.ts          # Domain entity
│       │   └── order-repository.interface.ts  # Repository interface
│       └── data/
│           ├── typeorm-order.repository.ts   # TypeORM implementation
│           └── order.orm-entity.ts            # TypeORM entity
└── app.module.ts
// business/order-repository.interface.ts — interface in business layer
export interface IOrderRepository {
  findById(id: string): Promise<Order | null>;
  findByUserId(userId: string): Promise<Order[]>;
  save(order: Order): Promise<Order>;
  delete(id: string): Promise<void>;
}
 
// data/typeorm-order.repository.ts — implementation in data layer
@Injectable()
export class TypeOrmOrderRepository implements IOrderRepository {
  constructor(
    @InjectRepository(OrderOrmEntity)
    private readonly ormRepo: Repository<OrderOrmEntity>,
  ) {}
 
  async findById(id: string): Promise<Order | null> {
    const entity = await this.ormRepo.findOne({ where: { id }, relations: ['items'] });
    return entity ? Order.fromOrmEntity(entity) : null;
  }
 
  async save(order: Order): Promise<Order> {
    const entity = await this.ormRepo.save(order.toOrmEntity());
    return Order.fromOrmEntity(entity);
  }
}

Common Mistakes

Mistake 1: The Anemic Domain Model

The most common and most harmful mistake in layered architecture. Domain objects become empty data bags — just getters and setters — with all logic stuffed into services.

// ❌ Anti-pattern: Anemic domain model
public class Order {
    private Long id;
    private OrderStatus status;
    private List<OrderItem> items;
    private BigDecimal total;
 
    // Just getters and setters. No behavior.
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public OrderStatus getStatus() { return status; }
    public void setStatus(OrderStatus status) { this.status = status; }
    // ... 20 more getters/setters
}
 
// All logic in the service (procedural, not OOP)
@Service
public class OrderService {
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
 
        // Business rules scattered in the service
        if (order.getStatus() == OrderStatus.DELIVERED) {
            throw new IllegalStateException("Cannot cancel delivered order");
        }
        if (order.getStatus() == OrderStatus.CANCELLED) {
            throw new IllegalStateException("Order already cancelled");
        }
 
        order.setStatus(OrderStatus.CANCELLED);  // Mutating state directly
        orderRepository.save(order);
    }
}
// ✅ Rich domain model — behavior lives with data
public class Order {
    private Long id;
    private OrderStatus status;
    private List<OrderItem> items;
 
    // Business logic belongs in the domain object
    public void cancel() {
        if (this.status == OrderStatus.DELIVERED) {
            throw new IllegalStateException("Cannot cancel delivered order");
        }
        if (this.status == OrderStatus.CANCELLED) {
            throw new IllegalStateException("Order already cancelled");
        }
        this.status = OrderStatus.CANCELLED;
    }
 
    public BigDecimal calculateTotal() {
        return items.stream()
                .map(OrderItem::getSubtotal)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}
 
// Service is now lean — orchestrates, doesn't duplicate logic
@Service
public class OrderService {
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.cancel();  // Domain logic encapsulated
        orderRepository.save(order);
    }
}

Mistake 2: Leaky Abstractions Between Layers

The presentation layer knows about database details. The service layer returns HTTP status codes. The repository layer formats JSON. These are all layer violations.

// ❌ Anti-pattern: Service layer leaking HTTP concerns
@Service
public class OrderService {
    public ResponseEntity<OrderResponse> createOrder(CreateOrderRequest request) {
        // Service should NOT return ResponseEntity — that's HTTP-specific
        // ...
        return ResponseEntity.status(201).body(new OrderResponse(order));
    }
}
 
// ❌ Anti-pattern: Presentation layer calling repository directly
@RestController
public class OrderController {
    private final OrderRepository orderRepository;  // Bypassing business layer
 
    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderRepository.findById(id).orElseThrow();  // No business logic applied
    }
}
// ✅ Clean layers — each layer speaks its own language
@Service
public class OrderService {
    // Returns domain objects, not HTTP responses
    public Order createOrder(CreateOrderCommand command) {
        // ...
        return savedOrder;  // Domain object, not HTTP response
    }
}
 
@RestController
public class OrderController {
    private final OrderService orderService;  // Only talks to service
 
    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(@RequestBody CreateOrderRequest request) {
        Order order = orderService.createOrder(request.toCommand());
        return ResponseEntity.status(201).body(OrderResponse.from(order));
        // HTTP concerns handled here in the presentation layer
    }
}

Mistake 3: Business Logic in the Presentation Layer

Validation and business rules bleeding into controllers:

// ❌ Anti-pattern: Business logic in the controller
router.post('/orders', async (req, res) => {
  const { userId, items } = req.body;
 
  // This business logic belongs in the service
  for (const item of items) {
    const product = await productRepo.findById(item.productId);
    if (product.stock < item.quantity) {
      return res.status(400).json({ error: `Insufficient stock for ${product.name}` });
    }
  }
 
  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  if (total < 10) {
    return res.status(400).json({ error: 'Minimum order value is $10' });
  }
 
  // ...
});
 
// ✅ Controller delegates, service handles logic
router.post('/orders', async (req, res) => {
  try {
    const order = await orderService.createOrder(req.body);
    res.status(201).json(OrderResponse.from(order));
  } catch (error) {
    if (error instanceof InsufficientStockError) {
      return res.status(400).json({ error: error.message });
    }
    if (error instanceof MinimumOrderValueError) {
      return res.status(400).json({ error: error.message });
    }
    throw error;
  }
});

Mistake 4: The "Smart" Database Layer

Repositories making decisions they shouldn't — filtering based on business rules, joining tables based on business context:

// ❌ Anti-pattern: Business logic in the repository
@Repository
public class OrderRepository {
    // Repository deciding what "recent" means and what's "important"
    // This is business logic, not data access logic
    public List<Order> findImportantRecentOrders() {
        return entityManager.createQuery(
            "SELECT o FROM Order o " +
            "WHERE o.total > 500 " +           // Business rule: what is "important"?
            "AND o.createdAt > :lastWeek " +   // Business rule: what is "recent"?
            "AND o.status != 'CANCELLED'"      // Business rule: exclude cancelled
        ).setParameter("lastWeek", LocalDateTime.now().minusWeeks(1))
         .getResultList();
    }
}
 
// ✅ Repository handles mechanics, service handles decisions
@Repository
public class OrderRepository {
    // Generic query — data access concern only
    public List<Order> findByStatusAndCreatedAfter(OrderStatus status, LocalDateTime since) {
        return entityManager.createQuery(
            "SELECT o FROM Order o WHERE o.status = :status AND o.createdAt > :since"
        ).setParameter("status", status)
         .setParameter("since", since)
         .getResultList();
    }
}
 
@Service
public class OrderService {
    // Business logic lives in the service
    public List<Order> findImportantRecentOrders() {
        LocalDateTime lastWeek = LocalDateTime.now().minusWeeks(1);
        List<Order> recentOrders = orderRepository.findByStatusAndCreatedAfter(
            OrderStatus.ACTIVE, lastWeek);
 
        // Business rule applied in service
        return recentOrders.stream()
                .filter(order -> order.getTotal().compareTo(new BigDecimal("500")) > 0)
                .collect(toList());
    }
}

When Layered Architecture Breaks Down

Layered architecture is a solid default, but it has failure modes.

Problem 1: Feature Silos vs Layer Silos

As the application grows, teams organized around layers (all the controllers team, all the services team) struggle with cross-layer coordination. A simple feature change touches the controller, service, and repository — requiring coordination across multiple teams.

Solution: Organize by feature modules, each with its own mini-layered stack. This is the modular monolith pattern from ARCH-2.

modules/
├── orders/
│   ├── OrderController.java
│   ├── OrderService.java
│   └── OrderRepository.java
├── products/
│   ├── ProductController.java
│   ├── ProductService.java
│   └── ProductRepository.java

Each feature owns its vertical slice. Teams can work on features independently.

Problem 2: Performance Overhead

Strict layering means every data access goes through multiple objects: controller → service → repository → database → back. For high-performance paths with thousands of requests per second, this overhead matters.

Solution: Profile first. If a specific path is a bottleneck, consider:

  • Caching at the service layer
  • Read models / query objects that bypass the domain model (CQRS, covered in ARCH-7)
  • Database views for complex read queries

Problem 3: Complex Domain Logic Becomes Unwieldy

For complex business domains (finance, healthcare, logistics), a flat service layer with orchestration-heavy services becomes a coordination nightmare. Services need to call other services, creating dependency chains that are hard to test and reason about.

Solution: Consider richer architectural patterns:

  • Hexagonal Architecture (ARCH-8) — for better testability and external system isolation
  • Domain-Driven Design (ARCH-10) — for complex business domains with rich behavior
  • CQRS (ARCH-7) — when read and write models diverge significantly

Problem 4: Distributed Layering in Microservices

In a microservices world, "layers" become entire services. "Calling the service layer" means an HTTP request. This introduces network latency, partial failure scenarios, and distributed tracing requirements that don't exist in a simple layered monolith.

Solution: Be intentional about when you move from a layered monolith to distributed services. The monolith-first strategy applies here.


Layered Architecture vs Other Patterns

AspectLayered ArchitectureHexagonal ArchitectureClean Architecture
OrganizationHorizontal layers (tech concern)Ports & adapters (inside-out)Concentric rings (policy vs detail)
Dependency ruleTop-downAlways inwardAlways inward
Domain isolationPartialStrongVery strong
TestabilityGoodExcellentExcellent
Learning curveLowMediumHigh
Best forMost CRUD appsComplex domains, many external systemsComplex domains, long-lived systems

For most web applications and REST APIs, layered architecture is the right starting point. Move to hexagonal or clean architecture when you feel the specific pain points they solve.


Testing Layered Architecture

The layered pattern enables excellent test isolation when done correctly:

// Unit test for service layer — mock the repository
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
 
    @Mock
    private OrderRepository orderRepository;
 
    @Mock
    private ProductRepository productRepository;
 
    @InjectMocks
    private OrderService orderService;
 
    @Test
    void shouldThrowWhenProductOutOfStock() {
        // Arrange
        Product product = new Product("prod-1", "Widget", 5); // stock: 5
        given(productRepository.findById("prod-1")).willReturn(Optional.of(product));
 
        CreateOrderCommand command = new CreateOrderCommand(
            "user-1",
            List.of(new OrderItemCommand("prod-1", 10)) // Requesting 10, only 5 in stock
        );
 
        // Act + Assert
        assertThatThrownBy(() -> orderService.createOrder(command))
                .isInstanceOf(InsufficientStockException.class);
 
        verify(orderRepository, never()).save(any()); // Order should not be saved
    }
}
 
// Integration test for repository layer — test with real database
@DataJpaTest
class OrderRepositoryTest {
 
    @Autowired
    private JpaOrderRepository orderRepository;
 
    @Test
    void shouldFindOrdersByUserId() {
        // ... test with H2 in-memory database
    }
}
 
// Integration test for presentation layer — mock the service
@WebMvcTest(OrderController.class)
class OrderControllerTest {
 
    @Autowired
    private MockMvc mockMvc;
 
    @MockBean
    private OrderService orderService;
 
    @Test
    void shouldReturn201WhenOrderCreated() throws Exception {
        given(orderService.createOrder(any())).willReturn(testOrder());
 
        mockMvc.perform(post("/api/orders")
                .contentType(APPLICATION_JSON)
                .content("""
                    {"userId": "1", "items": [{"productId": "p1", "quantity": 2}]}
                    """))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").exists());
    }
}

Each layer is tested independently with appropriate tooling:

  • Service layer: Pure unit tests with mocks — fast, no I/O
  • Repository layer: Slice tests with embedded database — integration but focused
  • Presentation layer: MockMvc / supertest — HTTP-level without real DB

Summary

Layered architecture provides a proven foundation for building maintainable web applications:

Core principles:

  • Each layer has a single, clear responsibility
  • Dependencies flow downward only
  • Higher layers depend on abstractions, not concrete implementations
  • Business logic belongs in the business layer — not controllers, not repositories

Common mistakes to avoid:

  • Anemic domain models (logic in service, empty domain objects)
  • Layer violations (controllers calling repositories, services returning HTTP responses)
  • Business logic in the presentation layer
  • Smart repositories that encode business decisions

When to use layered architecture:

  • Most CRUD applications and REST APIs
  • Teams new to architecture patterns
  • Systems where domain complexity is manageable
  • Starting point before considering more complex patterns

When to evolve:

  • Domain logic becomes too complex for a service layer
  • Need stronger testability and external system isolation → Hexagonal Architecture
  • Read and write models diverge significantly → CQRS
  • Distributed team ownership becomes important → Microservices

The goal isn't to follow the pattern perfectly — it's to deliver software that's easy to understand, test, and change.


What's Next in the Software Architecture Series

This is post 3 of 12 in the Software Architecture Patterns series:

Related posts:

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.