← Back to Blog
Hexagonal Architecture in Practice: Building the Catalyst PSA Platform

After 27 years of building software, I've seen architectural patterns come and go. But Hexagonal Architecture—also known as Ports and Adapters—has proven to be one of the most effective patterns for building maintainable enterprise applications. When we built the Catalyst PSA platform, Hexagonal Architecture was instrumental in keeping our codebase manageable and testable.

What is Hexagonal Architecture?

Hexagonal Architecture, introduced by Alistair Cockburn, is about creating a clear separation between your business logic and external concerns like databases, APIs, and user interfaces. The core idea is simple: your business logic shouldn't know or care about where data comes from or where it goes.

The architecture consists of three main layers:

  • Domain Layer: Your pure business logic
  • Application Layer: Use cases and orchestration
  • Infrastructure Layer: External dependencies (databases, APIs, message queues)

Why We Chose Hexagonal Architecture for Catalyst PSA

When you're building a platform that needs to scale to hundreds of thousands of lines of code and serve enterprise clients, maintainability becomes paramount. Here's why Hexagonal Architecture made sense:

1. Technology Independence

In enterprise SaaS, requirements change. Today you're using PostgreSQL, tomorrow the client needs MongoDB support. With Hexagonal Architecture, we could swap out our persistence layer without touching business logic. We actually did this when migrating from Prisma to Drizzle—the domain layer didn't need a single change.

2. Testability

Testing becomes trivial when your business logic doesn't depend on external services. We could write comprehensive unit tests for our domain layer without spinning up databases or mocking complex dependencies. Our test coverage exceeded 85% because testing was actually enjoyable.

3. Clear Boundaries

In large teams, clear boundaries prevent chaos. Developers knew exactly where code belonged. If it's business logic, it goes in the domain. If it's about how we store data, it's infrastructure.

Implementing Hexagonal Architecture with NestJS

Let's look at how we structured this in practice using NestJS. Here's a real example from our project management module:

The Domain Layer

// domain/entities/project.entity.ts
export class Project {
  constructor(
    private readonly id: string,
    private name: string,
    private status: ProjectStatus,
    private clientId: string,
    private startDate: Date,
    private endDate?: Date
  ) {}
 
  changeName(newName: string): void {
    if (!newName || newName.trim().length === 0) {
      throw new Error('Project name cannot be empty');
    }
    this.name = newName;
  }
 
  complete(completionDate: Date): void {
    if (completionDate < this.startDate) {
      throw new Error('Completion date cannot be before start date');
    }
    this.status = ProjectStatus.COMPLETED;
    this.endDate = completionDate;
  }
 
  // Pure business logic - no dependencies
}

Notice how the domain entity has zero dependencies. It's pure TypeScript with business rules.

The Port (Interface)

// domain/ports/project.repository.port.ts
export interface ProjectRepositoryPort {
  findById(id: string): Promise<Project | null>;
  findByClientId(clientId: string): Promise<Project[]>;
  save(project: Project): Promise<void>;
  delete(id: string): Promise<void>;
}

This is the contract that infrastructure must fulfill. The domain defines what it needs, not how it's implemented.

The Adapter (Implementation)

// infrastructure/persistence/project.repository.ts
@Injectable()
export class ProjectRepository implements ProjectRepositoryPort {
  constructor(private readonly db: DrizzleService) {}
 
  async findById(id: string): Promise<Project | null> {
    const result = await this.db
      .select()
      .from(projects)
      .where(eq(projects.id, id))
      .limit(1);
 
    if (!result.length) return null;
    return this.toDomain(result[0]);
  }
 
  async save(project: Project): Promise<void> {
    const data = this.toPersistence(project);
    await this.db
      .insert(projects)
      .values(data)
      .onConflictDoUpdate({
        target: projects.id,
        set: data,
      });
  }
 
  private toDomain(record: any): Project {
    // Map database record to domain entity
  }
 
  private toPersistence(project: Project): any {
    // Map domain entity to database record
  }
}

The adapter handles all the messy details of data persistence. The domain remains blissfully ignorant.

The Application Layer

// application/use-cases/complete-project.use-case.ts
@Injectable()
export class CompleteProjectUseCase {
  constructor(
    @Inject('ProjectRepositoryPort')
    private readonly projectRepository: ProjectRepositoryPort,
    private readonly eventBus: EventBus
  ) {}
 
  async execute(projectId: string, completionDate: Date): Promise<void> {
    const project = await this.projectRepository.findById(projectId);
 
    if (!project) {
      throw new NotFoundException('Project not found');
    }
 
    project.complete(completionDate);
 
    await this.projectRepository.save(project);
 
    this.eventBus.publish(new ProjectCompletedEvent(projectId));
  }
}

Use cases orchestrate the workflow but delegate business rules to the domain.

Lessons Learned from 320K+ Lines

Lesson 1: Don't Overthink the Domain Model

Early on, we tried to make our domain entities too smart, adding complex validation and business logic everywhere. This made them hard to construct and test. We learned to keep entities focused on their core invariants and move complex workflows to domain services.

Lesson 2: Mapping is Tedious but Essential

The mapping between domain entities and persistence models feels like boilerplate. We were tempted to skip it and use database models directly in our domain. Don't do this. The separation is worth its weight in gold when you need to change your database schema without breaking business logic.

Lesson 3: Feature Folders Work Better Than Layer Folders

Instead of organizing code by layer (all entities in one folder, all repositories in another), we organized by feature:

src/
  projects/
    domain/
    application/
    infrastructure/
  time-tracking/
    domain/
    application/
    infrastructure/

This made it easier to find related code and understand feature boundaries.

Lesson 4: Use Dependency Injection Wisely

NestJS's dependency injection made it easy to wire up ports and adapters. We registered repositories as providers:

const providers = [
  {
    provide: 'ProjectRepositoryPort',
    useClass: ProjectRepository,
  },
];

This made testing a breeze—just swap the implementation in tests.

The Results

After three years and a large codebase, Hexagonal Architecture proved its worth:

  • Zero regressions when we migrated from Prisma to Drizzle
  • 85%+ test coverage maintained as the codebase grew
  • Faster onboarding for new developers who could understand one layer at a time
  • Flexible deployment options—we could run the same core logic in serverless functions or long-running processes

When Not to Use Hexagonal Architecture

Hexagonal Architecture isn't free. It requires discipline and adds some complexity. For small projects or MVPs, it might be overkill. We used it for Catalyst PSA because we knew it would grow large and need to evolve over years.

If you're building a simple CRUD app that won't change much, stick with a simpler architecture. But if you're building enterprise software that needs to stand the test of time, Hexagonal Architecture is worth the investment.

Conclusion

Hexagonal Architecture transformed how we built the Catalyst PSA platform. It gave us the flexibility to evolve our technology choices, the confidence to refactor without fear, and the clarity to work as a large team on a massive codebase.

The key is starting with clean boundaries and maintaining the discipline not to let infrastructure concerns leak into your domain. It's not always easy, but after 27 years of writing code, I can tell you it's one of the best architectural decisions you can make for enterprise applications.

Share this article

Help others discover this content


Jason Cochran

Jason Cochran

Sofware Engineer | Cloud Consultant | Founder at Strataga

27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.