software-architecture

Decoupling Domain Logic: Implementing Clean Architecture

How we separate business core rules from external frameworks, libraries, and databases to build maintainable enterprise systems.

Summary

Estimating and building enterprise software is highly complex. The biggest driver of software cost is not the initial code generation—it is long-term maintenance. Systems that tie their business rules directly to databases or frameworks quickly accumulate technical debt, making modifications slow and expensive. This guide describes how SoftHouse structures application architectures using Clean Architecture (Ports & Adapters) to ensure flexibility and long-term viability.

Business Problem

Businesses often see their software development speed drop significantly after the first year of deployment. Simple modifications, like updating an API schema, changing a payment processor, or shifting from MySQL to PostgreSQL, require changes across the entire codebase. This architectural lock-in dramatically increases software maintenance costs, making it difficult for Egyptian and GCC enterprises to adapt to changing market requirements.

Engineering Challenge

Maintaining structural independence in a large TypeScript project involves three major technical hurdles:

  1. Framework Decoupling: Application core business rules must remain untouched if the framework (e.g. NestJS, Express, Next.js) is replaced or updated.
  2. Database Agnosticism: Core code must not import queries, ORMs, or database-specific entities directly.
  3. Inversion of Control: High-level core business policy should not depend on low-level storage details; instead, both must depend on abstractions.

Architecture

To enforce clean boundaries, we apply Ports & Adapters (Hexagonal) Architecture:

  1. Domain Core: Contains pure business rules, entities, and validation models.
  2. Ports: Interfaces that define how data flows in (Input Ports) and out (Output Ports) of the Core.
  3. Adapters: Concrete implementations of the Ports. For example, a PostgreSQL adapter implements the Database output port, and a REST Controller implements the Input port.
Architecture DiagramEnterprise Blueprint
EntitiesDOMAIN COREUse CasesAPPLICATION RULESControllers & AdaptersInput PortOutput Port
SoftHouse Clean Architecture structure representing decoupling of the Domain Core via Ports and Adapters.

Implementation

Below is a TypeScript implementation of a Use Case using Ports and Adapters. It shows how the business code depends entirely on a database interface (Port) rather than a concrete repository:

calculate-invoice.usecase.ts
class=class="text-[#f8c471]">"text-white/40 italic">// 1. Output Port: Abstraction for Database operations
export interface InvoiceRepositoryPort {
  saveInvoice(invoice: InvoiceEntity): Promise<void>;
  getTenantTaxRate(tenantId: string): Promise<number>;
}

class=class="text-[#f8c471]">"text-white/40 italic">// 2. Domain Entity: Pure business objects
export class InvoiceEntity {
constructor(
public readonly id: string,
public readonly tenantId: string,
public readonly baseAmount: number,
public readonly taxAmount: number
) {}
}

class=class="text-[#f8c471]">"text-white/40 italic">// 3. Use Case: Core business logic decoupled from frameworks and DBs
export class CalculateInvoiceUseCase {
constructor(
private readonly invoiceRepo: InvoiceRepositoryPort
) {}

async execute(tenantId: string, baseAmount: number): Promise<InvoiceEntity> {
class=class="text-[#f8c471]">"text-white/40 italic">// Core Business Rule: Retrieve tax rate from DB port and perform calculation
const taxRate = await this.invoiceRepo.getTenantTaxRate(tenantId);
const taxAmount = baseAmount \* (taxRate / 100);

    const invoice = new InvoiceEntity(
      Math.random().toString(36).substr(2, 9),
      tenantId,
      baseAmount,
      taxAmount
    );

    class=class="text-[#f8c471]">"text-white/40 italic">// Save invoice using the abstract Port
    await this.invoiceRepo.saveInvoice(invoice);
    return invoice;

}
}

Lessons Learned

Implementing decoupled architectures across large projects highlighted several software design principles:

Interface Boundaries
Always place Ports inside the Domain/Application directory, and Adapters in the Infrastructure directory. The domain must never import files from the infrastructure directory. Enforce this rule using ESLint import-boundaries checks.
  • Avoid Shared Entities: Do not pass TypeORM or Prisma database model classes into your use cases. Create lightweight business entities instead, mapping them at the database adapter boundary.
  • Write Unit Tests without Mocks: Because use cases depend purely on interfaces, you can test them using simple in-memory mock repositories instead of spinning up actual PostgreSQL instances in your CI.

Business Outcome

By adopting Clean Architecture across our custom platforms:

  • 80% faster onboarding for new developers due to modular folder isolation.
  • 0 code changes required in domain cores during database upgrades or ORM migrations.
  • Test coverage increased to 95% on core use cases with lightweight unit testing.

Key Takeaways

  1. Keep Core Pure: Do not mix HTTP handlers, SQL commands, or environment variables in your use case files.
  2. Depend on Abstractions: Write interfaces for database repository classes, email engines, and external APIs.
  3. Map at Boundaries: Convert infrastructure data schemas into clean domain objects at adapter endpoints.

LET’S BUILD SOMETHING USEFUL

Your challenge could be our next success story.

Tell us what you’re working on and what’s getting in the way. We’ll help you find the right next step.

Start a Project