Skip to content
Published on

Clean Architecture and Hexagonal Architecture in Practice: Achieving Domain Independence with Ports and Adapters

Authors
  • Name
    Twitter
Clean Architecture and Hexagonal Architecture

Introduction

As software grows, the first thing to collapse is usually the architecture. It often starts with a simple layered architecture, but many developers experience situations where business logic leaks into controllers, database schema changes affect domain logic, and framework upgrades shake the entire codebase.

Clean Architecture and Hexagonal Architecture (Ports and Adapters) are representative approaches to solving these problems. Both architectures aim to isolate core business logic from external concerns (databases, frameworks, UI).

This article compares the theoretical backgrounds of both architectures and then explains how to implement them in practice with TypeScript and Python code examples.

Architecture Comparison: Hexagonal vs Clean vs Onion vs Layered

Core Concept Comparison Table

ItemHexagonal ArchitectureClean ArchitectureOnion ArchitectureLayered Architecture
AuthorAlistair Cockburn (2005)Robert C. Martin (2012)Jeffrey Palermo (2008)Traditional pattern
Core PrinciplePorts and AdaptersDependency RuleDomain-centric layersUnidirectional layer deps
Layer StructureCore + Ports + AdaptersEntities/Use Cases/Interface Adapters/FrameworksDomain/Services/InfrastructurePresentation/Business/Data
Dependency DirectionOutside -> InsideOutside -> InsideOutside -> InsideTop -> Bottom
TestabilityHighHighHighModerate
Infra SwapSimple adapter replacementOuter layer replacementOuter layer replacementFull layer modification
Learning CurveModerateHighModerateLow
Suitable ScaleMedium to LargeLargeMedium to LargeSmall to Medium

Clean Architecture's Dependency Rule

The core of Clean Architecture is the Dependency Rule. Dependencies must always point inward -- from the outer layers toward the inner layers.

It consists of four layers from innermost to outermost:

  1. Entities: Enterprise business rules. No external dependencies whatsoever.
  2. Use Cases: Application business rules. Depend only on entities.
  3. Interface Adapters: Connect use cases to the outside world. Controllers, presenters, and gateways reside here.
  4. Frameworks & Drivers: Web frameworks, databases, UI, and other external tools.

Hexagonal Architecture's Ports and Adapters

Hexagonal Architecture represents the application as a hexagon and defines two types of ports:

  • Inbound Ports (Driving Ports): Interfaces for the outside world to invoke the application (e.g., use case interfaces)
  • Outbound Ports (Driven Ports): Interfaces for the application to access external systems (e.g., repository interfaces)

Each port has a corresponding adapter:

  • Inbound Adapters (Driving Adapters): HTTP controllers, CLI, message listeners, etc.
  • Outbound Adapters (Driven Adapters): DB repositories, external API clients, message publishers, etc.

TypeScript Practical Implementation

Project Structure

src/
  domain/           # Domain layer (entities, value objects)
    entities/
      Order.ts
      Product.ts
    value-objects/
      Money.ts
      OrderStatus.ts
  application/      # Application layer (use cases, ports)
    ports/
      inbound/
        CreateOrderUseCase.ts
        GetOrderUseCase.ts
      outbound/
        OrderRepository.ts
        PaymentGateway.ts
        EventPublisher.ts
    services/
      CreateOrderService.ts
      GetOrderService.ts
  infrastructure/   # Infrastructure layer (adapters)
    adapters/
      inbound/
        http/
          OrderController.ts
        messaging/
          OrderEventListener.ts
      outbound/
        persistence/
          TypeOrmOrderRepository.ts
        payment/
          StripePaymentAdapter.ts
        messaging/
          KafkaEventPublisher.ts
    config/
      DependencyContainer.ts

Domain Layer: Entities and Value Objects

// domain/value-objects/Money.ts
export class Money {
  private constructor(
    private readonly amount: number,
    private readonly currency: string
  ) {
    if (amount < 0) {
      throw new Error('Amount cannot be negative')
    }
  }

  static of(amount: number, currency: string = 'KRW'): Money {
    return new Money(amount, currency)
  }

  add(other: Money): Money {
    this.ensureSameCurrency(other)
    return Money.of(this.amount + other.amount, this.currency)
  }

  multiply(factor: number): Money {
    return Money.of(this.amount * factor, this.currency)
  }

  isGreaterThan(other: Money): boolean {
    this.ensureSameCurrency(other)
    return this.amount > other.amount
  }

  getAmount(): number {
    return this.amount
  }

  private ensureSameCurrency(other: Money): void {
    if (this.currency !== other.currency) {
      throw new Error('Currency mismatch')
    }
  }
}

// domain/value-objects/OrderStatus.ts
export enum OrderStatus {
  PENDING = 'PENDING',
  CONFIRMED = 'CONFIRMED',
  PAID = 'PAID',
  SHIPPED = 'SHIPPED',
  DELIVERED = 'DELIVERED',
  CANCELLED = 'CANCELLED',
}
// domain/entities/Order.ts
import { Money } from '../value-objects/Money'
import { OrderStatus } from '../value-objects/OrderStatus'

export interface OrderItem {
  productId: string
  productName: string
  quantity: number
  unitPrice: Money
}

export class Order {
  private constructor(
    private readonly id: string,
    private readonly customerId: string,
    private items: OrderItem[],
    private status: OrderStatus,
    private readonly createdAt: Date
  ) {}

  static create(id: string, customerId: string, items: OrderItem[]): Order {
    if (items.length === 0) {
      throw new Error('Order must have at least one item')
    }
    return new Order(id, customerId, items, OrderStatus.PENDING, new Date())
  }

  confirm(): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new Error('Only pending orders can be confirmed')
    }
    this.status = OrderStatus.CONFIRMED
  }

  pay(): void {
    if (this.status !== OrderStatus.CONFIRMED) {
      throw new Error('Only confirmed orders can be paid')
    }
    this.status = OrderStatus.PAID
  }

  cancel(): void {
    if (this.status === OrderStatus.SHIPPED || this.status === OrderStatus.DELIVERED) {
      throw new Error('Cannot cancel shipped or delivered orders')
    }
    this.status = OrderStatus.CANCELLED
  }

  getTotalAmount(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.unitPrice.multiply(item.quantity)),
      Money.of(0)
    )
  }

  getId(): string {
    return this.id
  }
  getCustomerId(): string {
    return this.customerId
  }
  getStatus(): OrderStatus {
    return this.status
  }
  getItems(): ReadonlyArray<OrderItem> {
    return [...this.items]
  }
}

Application Layer: Ports and Use Cases

// application/ports/inbound/CreateOrderUseCase.ts
export interface CreateOrderCommand {
  customerId: string
  items: Array<{
    productId: string
    productName: string
    quantity: number
    unitPrice: number
  }>
}

export interface CreateOrderResult {
  orderId: string
  totalAmount: number
  status: string
}

export interface CreateOrderUseCase {
  execute(command: CreateOrderCommand): Promise<CreateOrderResult>
}

// application/ports/outbound/OrderRepository.ts
import { Order } from '../../domain/entities/Order'

export interface OrderRepository {
  save(order: Order): Promise<void>
  findById(id: string): Promise<Order | null>
  findByCustomerId(customerId: string): Promise<Order[]>
}

// application/ports/outbound/PaymentGateway.ts
export interface PaymentRequest {
  orderId: string
  amount: number
  currency: string
}

export interface PaymentResult {
  transactionId: string
  success: boolean
  errorMessage?: string
}

export interface PaymentGateway {
  processPayment(request: PaymentRequest): Promise<PaymentResult>
}

// application/ports/outbound/EventPublisher.ts
export interface DomainEvent {
  eventType: string
  aggregateId: string
  payload: Record<string, unknown>
  occurredAt: Date
}

export interface EventPublisher {
  publish(event: DomainEvent): Promise<void>
}
// application/services/CreateOrderService.ts
import {
  CreateOrderUseCase,
  CreateOrderCommand,
  CreateOrderResult,
} from '../ports/inbound/CreateOrderUseCase'
import { OrderRepository } from '../ports/outbound/OrderRepository'
import { PaymentGateway } from '../ports/outbound/PaymentGateway'
import { EventPublisher } from '../ports/outbound/EventPublisher'
import { Order, OrderItem } from '../../domain/entities/Order'
import { Money } from '../../domain/value-objects/Money'
import { v4 as uuidv4 } from 'uuid'

export class CreateOrderService implements CreateOrderUseCase {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly paymentGateway: PaymentGateway,
    private readonly eventPublisher: EventPublisher
  ) {}

  async execute(command: CreateOrderCommand): Promise<CreateOrderResult> {
    // 1. Create domain objects
    const orderItems: OrderItem[] = command.items.map((item) => ({
      productId: item.productId,
      productName: item.productName,
      quantity: item.quantity,
      unitPrice: Money.of(item.unitPrice),
    }))

    const order = Order.create(uuidv4(), command.customerId, orderItems)

    // 2. Confirm order
    order.confirm()

    // 3. Process payment
    const paymentResult = await this.paymentGateway.processPayment({
      orderId: order.getId(),
      amount: order.getTotalAmount().getAmount(),
      currency: 'KRW',
    })

    if (!paymentResult.success) {
      order.cancel()
      await this.orderRepository.save(order)
      throw new Error('Payment failed: ' + (paymentResult.errorMessage || 'Unknown error'))
    }

    // 4. Mark as paid
    order.pay()

    // 5. Persist
    await this.orderRepository.save(order)

    // 6. Publish domain event
    await this.eventPublisher.publish({
      eventType: 'OrderCreated',
      aggregateId: order.getId(),
      payload: {
        customerId: order.getCustomerId(),
        totalAmount: order.getTotalAmount().getAmount(),
        itemCount: order.getItems().length,
      },
      occurredAt: new Date(),
    })

    return {
      orderId: order.getId(),
      totalAmount: order.getTotalAmount().getAmount(),
      status: order.getStatus(),
    }
  }
}

Infrastructure Layer: Adapter Implementations

// infrastructure/adapters/outbound/persistence/TypeOrmOrderRepository.ts
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Repository, DataSource } from 'typeorm'

import { OrderEntity } from './entities/OrderEntity'
import { OrderItemEntity } from './entities/OrderItemEntity'

export class TypeOrmOrderRepository implements OrderRepository {
  private readonly repository: Repository<OrderEntity>

  constructor(dataSource: DataSource) {
    this.repository = dataSource.getRepository(OrderEntity)
  }

  async save(order: Order): Promise<void> {
    const entity = this.toEntity(order)
    await this.repository.save(entity)
  }

  async findById(id: string): Promise<Order | null> {
    const entity = await this.repository.findOne({
      where: { id },
      relations: ['items'],
    })
    return entity ? this.toDomain(entity) : null
  }

  async findByCustomerId(customerId: string): Promise<Order[]> {
    const entities = await this.repository.find({
      where: { customerId },
      relations: ['items'],
    })
    return entities.map((e) => this.toDomain(e))
  }

  private toEntity(order: Order): OrderEntity {
    const entity = new OrderEntity()
    entity.id = order.getId()
    entity.customerId = order.getCustomerId()
    entity.status = order.getStatus()
    entity.totalAmount = order.getTotalAmount().getAmount()
    return entity
  }

  private toDomain(entity: OrderEntity): Order {
    return Order.create(entity.id, entity.customerId, /* items mapping */ [])
  }
}
// infrastructure/adapters/inbound/http/OrderController.ts
import { Router, Request, Response } from 'express'
import { CreateOrderUseCase } from '../../../application/ports/inbound/CreateOrderUseCase'

export class OrderController {
  private readonly router: Router

  constructor(private readonly createOrderUseCase: CreateOrderUseCase) {
    this.router = Router()
    this.setupRoutes()
  }

  private setupRoutes(): void {
    this.router.post('/orders', this.createOrder.bind(this))
  }

  private async createOrder(req: Request, res: Response): Promise<void> {
    try {
      const result = await this.createOrderUseCase.execute({
        customerId: req.body.customerId,
        items: req.body.items,
      })
      res.status(201).json(result)
    } catch (error) {
      if (error instanceof Error) {
        res.status(400).json({ error: error.message })
      } else {
        res.status(500).json({ error: 'Internal server error' })
      }
    }
  }

  getRouter(): Router {
    return this.router
  }
}

DI Container Setup

// infrastructure/config/DependencyContainer.ts
import { DataSource } from 'typeorm'
import { CreateOrderService } from '../../application/services/CreateOrderService'
import { TypeOrmOrderRepository } from '../adapters/outbound/persistence/TypeOrmOrderRepository'
import { StripePaymentAdapter } from '../adapters/outbound/payment/StripePaymentAdapter'
import { KafkaEventPublisher } from '../adapters/outbound/messaging/KafkaEventPublisher'
import { OrderController } from '../adapters/inbound/http/OrderController'

export class DependencyContainer {
  private static instance: DependencyContainer

  private constructor(private readonly dataSource: DataSource) {}

  static async create(dataSource: DataSource): Promise<DependencyContainer> {
    if (!DependencyContainer.instance) {
      DependencyContainer.instance = new DependencyContainer(dataSource)
    }
    return DependencyContainer.instance
  }

  createOrderController(): OrderController {
    // Create outbound adapters
    const orderRepository = new TypeOrmOrderRepository(this.dataSource)
    const paymentGateway = new StripePaymentAdapter()
    const eventPublisher = new KafkaEventPublisher()

    // Create use case (inject port implementations)
    const createOrderService = new CreateOrderService(
      orderRepository,
      paymentGateway,
      eventPublisher
    )

    // Create inbound adapter
    return new OrderController(createOrderService)
  }
}

Python Practical Implementation

Project Structure and Domain Layer

# domain/entities/order.py
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import List
from domain.value_objects.money import Money


class OrderStatus(Enum):
    PENDING = "PENDING"
    CONFIRMED = "CONFIRMED"
    PAID = "PAID"
    SHIPPED = "SHIPPED"
    CANCELLED = "CANCELLED"


@dataclass
class OrderItem:
    product_id: str
    product_name: str
    quantity: int
    unit_price: Money


class Order:
    def __init__(
        self,
        order_id: str,
        customer_id: str,
        items: List[OrderItem],
        status: OrderStatus = OrderStatus.PENDING,
        created_at: datetime = None,
    ):
        if not items:
            raise ValueError("Order must have at least one item")
        self._id = order_id
        self._customer_id = customer_id
        self._items = items
        self._status = status
        self._created_at = created_at or datetime.utcnow()

    def confirm(self) -> None:
        if self._status != OrderStatus.PENDING:
            raise ValueError("Only pending orders can be confirmed")
        self._status = OrderStatus.CONFIRMED

    def pay(self) -> None:
        if self._status != OrderStatus.CONFIRMED:
            raise ValueError("Only confirmed orders can be paid")
        self._status = OrderStatus.PAID

    def cancel(self) -> None:
        if self._status in (OrderStatus.SHIPPED,):
            raise ValueError("Cannot cancel shipped orders")
        self._status = OrderStatus.CANCELLED

    @property
    def total_amount(self) -> Money:
        total = Money(0)
        for item in self._items:
            total = total.add(item.unit_price.multiply(item.quantity))
        return total

    @property
    def id(self) -> str:
        return self._id

    @property
    def customer_id(self) -> str:
        return self._customer_id

    @property
    def status(self) -> OrderStatus:
        return self._status

Port Definitions (Abstract Classes)

# application/ports/outbound/order_repository.py
from abc import ABC, abstractmethod
from typing import Optional, List
from domain.entities.order import Order


class OrderRepository(ABC):
    @abstractmethod
    async def save(self, order: Order) -> None:
        pass

    @abstractmethod
    async def find_by_id(self, order_id: str) -> Optional[Order]:
        pass

    @abstractmethod
    async def find_by_customer_id(self, customer_id: str) -> List[Order]:
        pass


# application/ports/outbound/payment_gateway.py
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass
class PaymentRequest:
    order_id: str
    amount: float
    currency: str


@dataclass
class PaymentResult:
    transaction_id: str
    success: bool
    error_message: str = ""


class PaymentGateway(ABC):
    @abstractmethod
    async def process_payment(self, request: PaymentRequest) -> PaymentResult:
        pass

Use Case Implementation

# application/services/create_order_service.py
import uuid
from dataclasses import dataclass
from typing import List
from domain.entities.order import Order, OrderItem
from domain.value_objects.money import Money
from application.ports.outbound.order_repository import OrderRepository
from application.ports.outbound.payment_gateway import PaymentGateway, PaymentRequest


@dataclass
class CreateOrderCommand:
    customer_id: str
    items: List[dict]


@dataclass
class CreateOrderResult:
    order_id: str
    total_amount: float
    status: str


class CreateOrderService:
    def __init__(
        self,
        order_repository: OrderRepository,
        payment_gateway: PaymentGateway,
    ):
        self._order_repository = order_repository
        self._payment_gateway = payment_gateway

    async def execute(self, command: CreateOrderCommand) -> CreateOrderResult:
        items = [
            OrderItem(
                product_id=item["product_id"],
                product_name=item["product_name"],
                quantity=item["quantity"],
                unit_price=Money(item["unit_price"]),
            )
            for item in command.items
        ]

        order = Order(
            order_id=str(uuid.uuid4()),
            customer_id=command.customer_id,
            items=items,
        )

        order.confirm()

        payment_result = await self._payment_gateway.process_payment(
            PaymentRequest(
                order_id=order.id,
                amount=order.total_amount.amount,
                currency="KRW",
            )
        )

        if not payment_result.success:
            order.cancel()
            await self._order_repository.save(order)
            raise Exception(f"Payment failed: {payment_result.error_message}")

        order.pay()
        await self._order_repository.save(order)

        return CreateOrderResult(
            order_id=order.id,
            total_amount=order.total_amount.amount,
            status=order.status.value,
        )

Testing Strategy

Domain Unit Tests

The domain layer has no external dependencies, enabling pure unit testing.

// tests/domain/entities/Order.test.ts
import { Order, OrderItem } from '../../../domain/entities/Order'
import { Money } from '../../../domain/value-objects/Money'
import { OrderStatus } from '../../../domain/value-objects/OrderStatus'

describe('Order', () => {
  const sampleItems: OrderItem[] = [
    {
      productId: 'prod-1',
      productName: 'Widget',
      quantity: 2,
      unitPrice: Money.of(10000),
    },
  ]

  it('should create an order with PENDING status', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    expect(order.getStatus()).toBe(OrderStatus.PENDING)
  })

  it('should throw when creating order with empty items', () => {
    expect(() => Order.create('order-1', 'customer-1', [])).toThrow(
      'Order must have at least one item'
    )
  })

  it('should calculate total amount correctly', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    expect(order.getTotalAmount().getAmount()).toBe(20000)
  })

  it('should transition from PENDING to CONFIRMED', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    order.confirm()
    expect(order.getStatus()).toBe(OrderStatus.CONFIRMED)
  })

  it('should not allow paying before confirming', () => {
    const order = Order.create('order-1', 'customer-1', sampleItems)
    expect(() => order.pay()).toThrow('Only confirmed orders can be paid')
  })
})

Use Case Integration Tests (Mocking Adapters)

// tests/application/services/CreateOrderService.test.ts
import { CreateOrderService } from '../../../application/services/CreateOrderService'
import { OrderRepository } from '../../../application/ports/outbound/OrderRepository'
import { PaymentGateway } from '../../../application/ports/outbound/PaymentGateway'
import { EventPublisher } from '../../../application/ports/outbound/EventPublisher'

describe('CreateOrderService', () => {
  let service: CreateOrderService
  let mockOrderRepo: jest.Mocked<OrderRepository>
  let mockPayment: jest.Mocked<PaymentGateway>
  let mockEvents: jest.Mocked<EventPublisher>

  beforeEach(() => {
    mockOrderRepo = {
      save: jest.fn().mockResolvedValue(undefined),
      findById: jest.fn(),
      findByCustomerId: jest.fn(),
    }
    mockPayment = {
      processPayment: jest.fn().mockResolvedValue({
        transactionId: 'tx-1',
        success: true,
      }),
    }
    mockEvents = {
      publish: jest.fn().mockResolvedValue(undefined),
    }

    service = new CreateOrderService(mockOrderRepo, mockPayment, mockEvents)
  })

  it('should create and pay for an order successfully', async () => {
    const result = await service.execute({
      customerId: 'cust-1',
      items: [
        {
          productId: 'prod-1',
          productName: 'Widget',
          quantity: 1,
          unitPrice: 10000,
        },
      ],
    })

    expect(result.status).toBe('PAID')
    expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
    expect(mockPayment.processPayment).toHaveBeenCalledTimes(1)
    expect(mockEvents.publish).toHaveBeenCalledTimes(1)
  })

  it('should cancel order when payment fails', async () => {
    mockPayment.processPayment.mockResolvedValue({
      transactionId: '',
      success: false,
      errorMessage: 'Insufficient funds',
    })

    await expect(
      service.execute({
        customerId: 'cust-1',
        items: [
          {
            productId: 'prod-1',
            productName: 'Widget',
            quantity: 1,
            unitPrice: 10000,
          },
        ],
      })
    ).rejects.toThrow('Payment failed')

    expect(mockOrderRepo.save).toHaveBeenCalledTimes(1)
  })
})

Anti-Patterns and Caveats

Anti-Pattern 1: Domain Depending on Infrastructure

// Bad: Domain entity depends on TypeORM decorators
import { Entity, Column, PrimaryColumn } from 'typeorm'

@Entity() // Infrastructure framework dependency!
export class Order {
  @PrimaryColumn()
  id: string

  @Column()
  status: string
}

// Good: Domain entity remains a pure class
// Persistence mapping handled by separate Entity classes in infrastructure layer
export class Order {
  private readonly id: string
  private status: OrderStatus
  // Contains only pure business logic
}

Anti-Pattern 2: Anemic Domain Model

// Bad: Domain entity is just a data structure
export class Order {
  id: string
  status: string
  items: OrderItem[]
  totalAmount: number
  // Only getters/setters, no business logic
}

// All business logic concentrated in services
export class OrderService {
  confirmOrder(order: Order): void {
    if (order.status !== 'PENDING') throw new Error('...')
    order.status = 'CONFIRMED'
    order.totalAmount = this.calculateTotal(order.items)
  }
}

// Good: Business logic encapsulated within domain entity
export class Order {
  confirm(): void {
    if (this.status !== OrderStatus.PENDING) {
      throw new Error('Only pending orders can be confirmed')
    }
    this.status = OrderStatus.CONFIRMED
  }

  getTotalAmount(): Money {
    return this.items.reduce(
      (total, item) => total.add(item.unitPrice.multiply(item.quantity)),
      Money.of(0)
    )
  }
}

Anti-Pattern 3: Over-Engineering

Applying hexagonal architecture to a small CRUD application only increases boilerplate code and reduces productivity. Architecture choices should match the project's complexity and expected lifespan.

Migration from Layered Architecture

Step-by-Step Migration Strategy

When transitioning from a layered architecture to hexagonal/clean architecture, take an incremental approach:

  1. Phase 1: Separate the domain layer -- Move business logic from services into entities
  2. Phase 2: Define ports (interfaces) -- Extract interfaces for repositories and external services
  3. Phase 3: Implement adapters -- Wrap existing implementations as adapters
  4. Phase 4: Configure DI container -- Connect ports and adapters via dependency injection
  5. Phase 5: Add tests -- Write domain unit tests and port mocking tests

Failure Cases in Boundary Decisions

Incorrect boundary definitions can negate the benefits of the architecture. For example, if a single use case depends on too many outbound ports, it signals that the use case has too many responsibilities. It is better to split the use case into smaller units.

Conversely, splitting ports too granularly causes the number of ports to explode, making management difficult. As a general rule, one Repository port per Aggregate Root and one Gateway port per external system is appropriate.

Production Checklist

Architecture Principles

  • Verify dependencies always point from outside to inside
  • Verify no framework imports exist in the domain layer
  • Verify domain entities encapsulate sufficient business logic
  • Verify port interfaces are defined in domain terminology (not technical terms)
  • Verify DI container is only used at the Composition Root

Testing

  • Domain layer unit test coverage above 90%
  • Verify outbound ports are mocked in use case tests
  • Verify adapter integration tests exist
  • Verify E2E tests validate the complete flow

Code Quality

  • Verify no circular dependencies between packages
  • Verify single responsibility per use case (SRP)
  • Verify port interfaces define minimal methods (ISP)
  • Automate architecture rule verification with ArchUnit or similar tools

References