Hexagonal Architecture
Hexagonal Architecture
Global Watch uses Hexagonal Architecture (also known as Ports & Adapters) to isolate business logic from infrastructure concerns. This architectural pattern ensures that the core domain logic remains pure and testable, independent of external systems like databases, APIs, or frameworks.
Overview
The Hexagonal Architecture organizes code into three main layers:
apps/web/
├── core/ # Business logic (framework-agnostic)
│ ├── domain/ # Entities, Value Objects, Repository interfaces
│ ├── application/ # Use cases (Commands, Queries, Strategies)
│ └── shared/ # Shared types (Result, errors)
│
└── infrastructure/ # External adapters
└── database/supabase/ # Supabase repository implementationsDomain Layer
The domain layer (core/domain/) contains pure business logic with no external dependencies. This is the heart of the application.
Entities
Rich domain models encapsulate both data and behavior:
// core/domain/project/project.entity.ts
export class Project {
// Private constructor - use factory methods
private constructor(
public readonly id: string,
private _name: string,
private _geometry: Geometry | null,
// ...
) {}
// Factory methods
static create(params: CreateProjectParams): Project { }
static fromPersistence(data: ProjectRow): Project { }
// Behavior methods
updateName(newName: string): void { }
calculateArea(): number { }
archive(): void { }
canBeDeleted(): boolean { }
// Serialization
toPersistence(): ProjectRow { }
toJSON(): ProjectDTO { }
}Key characteristics of entities:
- Private constructor - Use factory methods for creation
- Behavior over setters - Methods like
updateName()instead ofsetName() - Encapsulated state - Private fields with controlled access
- Validation on construction - Entities are always valid
Repository Interfaces (Ports)
Ports define contracts for data access without specifying implementation:
// core/domain/project/project.repository.ts
export interface IProjectRepository {
create(project: ProjectInsert): Promise<Result<Project, ProjectError>>;
findById(id: string): Promise<Result<Project, ProjectNotFoundError>>;
update(id: string, updates: ProjectUpdate): Promise<Result<Project, ProjectError>>;
delete(id: string): Promise<Result<void, ProjectError>>;
list(filters?: ProjectFilters): Promise<Result<PaginatedResult<Project>, ProjectError>>;
}Benefits of repository interfaces:
- Testability - Mock repositories for unit tests
- Flexibility - Swap implementations without changing domain
- Clarity - Clear contract for data operations
Value Objects
Immutable objects representing domain concepts:
// core/domain/value-objects/hectares.vo.ts
export class Hectares {
private constructor(private readonly value: number) {}
static create(value: number): Result<Hectares, ValidationError> {
if (value < 0) {
return Result.err(new ValidationError('Hectares must be positive'));
}
return Result.ok(new Hectares(value));
}
toNumber(): number { return this.value; }
add(other: Hectares): Hectares {
return new Hectares(this.value + other.value);
}
}Common value objects in Global Watch:
Hectares- Area measurementsEmail- Validated email addressesMoney- Currency amountsSlug- URL-safe identifiers
Application Layer
The application layer (core/application/) orchestrates use cases using domain objects.
Commands (Write Operations)
Commands handle state-changing operations:
// core/application/commands/project/create-project.command.ts
export class CreateProjectCommand {
constructor(
private readonly repository: IProjectRepository,
private readonly usageService: IUsageService,
) {}
async execute(params: CreateProjectParams): Promise<Result<Project, ProjectError>> {
// Create domain entity
const project = Project.create(params);
// Persist through repository
const result = await this.repository.create(project.toPersistence());
// Side effects
if (result.ok && project.requiresUsageTracking()) {
await this.usageService.trackHectares(project);
}
return result;
}
}Queries (Read Operations)
Queries handle data retrieval:
// core/application/queries/project/get-project.query.ts
export class GetProjectQuery {
constructor(private readonly repository: IProjectRepository) {}
async execute(projectId: string): Promise<Result<Project, ProjectNotFoundError>> {
return this.repository.findById(projectId);
}
}CQRS Pattern
Global Watch separates Commands and Queries for clarity:
| Type | Purpose | Side Effects |
|---|---|---|
| Command | Write operations | Yes |
| Query | Read operations | No |
Infrastructure Layer
The infrastructure layer (infrastructure/) implements ports with concrete technologies.
Supabase Adapter
Repository implementations using Supabase:
// infrastructure/database/supabase/supabase-project.repository.ts
export class SupabaseProjectRepository implements IProjectRepository {
constructor(private readonly client: SupabaseClient<Database>) {}
async findById(projectId: string): Promise<Result<Project, ProjectNotFoundError>> {
const { data, error } = await this.client
.from('projects')
.select('*')
.eq('id', projectId)
.maybeSingle();
if (error || !data) {
return Result.err(new ProjectNotFoundError(projectId));
}
return Result.ok(Project.fromPersistence(data));
}
async create(project: ProjectInsert): Promise<Result<Project, ProjectError>> {
const { data, error } = await this.client
.from('projects')
.insert(project)
.select()
.single();
if (error) {
return Result.err(new ProjectError(error.message));
}
return Result.ok(Project.fromPersistence(data));
}
}Rules and Best Practices
1. Domain Has No Dependencies
The domain layer must not import from infrastructure or frameworks:
// ✅ CORRECT - Domain imports only from domain/shared
import { Result } from '~/core/shared/result';
import { Hectares } from '../value-objects/hectares.vo';
// ❌ WRONG - Domain importing from infrastructure
import { supabase } from '~/infrastructure/database/supabase';2. Server-Only Domain
Domain entities should be server-only:
// core/domain/project/project.entity.ts
import 'server-only';
export class Project {
// ...
}3. Factory Methods for Creation
Never use new Entity() directly:
// ✅ CORRECT - Use factory methods
const project = Project.create({ name: 'My Project' });
const project = Project.fromPersistence(dbRow);
// ❌ WRONG - Direct instantiation
const project = new Project(id, name, geometry);4. Behavior Over Setters
Use methods that express intent:
// ✅ CORRECT - Behavior methods
project.archive();
project.updateName('New Name');
project.addMember(userId, role);
// ❌ WRONG - Setters
project.setIsArchived(true);
project.setName('New Name');5. Result Type for Errors
Return Result<T, E> instead of throwing:
// ✅ CORRECT - Return Result
async findById(id: string): Promise<Result<Project, ProjectNotFoundError>> {
const data = await this.fetch(id);
if (!data) {
return Result.err(new ProjectNotFoundError(id));
}
return Result.ok(Project.fromPersistence(data));
}
// ❌ WRONG - Throwing exceptions
async findById(id: string): Promise<Project> {
const data = await this.fetch(id);
if (!data) {
throw new Error('Project not found');
}
return Project.fromPersistence(data);
}6. One Repository Per Aggregate
Each aggregate root has its own repository:
// ✅ CORRECT - Repository per aggregate
IProjectRepository // For Project aggregate
IAccountRepository // For Account aggregate
IAssetRepository // For Asset aggregate
// ❌ WRONG - Generic repository
IGenericRepository<T>Testing Benefits
The hexagonal architecture enables effective testing:
Unit Testing Domain Logic
describe('Project', () => {
it('should calculate area from geometry', () => {
const project = Project.create({
name: 'Test',
geometry: mockPolygon,
});
expect(project.calculateArea()).toBeCloseTo(100.5);
});
it('should not allow deletion when not archived', () => {
const project = Project.create({ name: 'Test' });
expect(project.canBeDeleted()).toBe(false);
});
});Testing with Mock Repositories
describe('CreateProjectCommand', () => {
it('should create project and track usage', async () => {
const mockRepo = createMockRepository();
const mockUsage = createMockUsageService();
const command = new CreateProjectCommand(mockRepo, mockUsage);
const result = await command.execute({ name: 'Test' });
expect(result.ok).toBe(true);
expect(mockUsage.trackHectares).toHaveBeenCalled();
});
});Available Entities
| Entity | Location | Key Behavior |
|---|---|---|
Project | core/domain/project/ | calculateArea(), archive(), restore() |
Account | core/domain/account/ | Account management |
Asset | core/domain/asset/ | Geolocated items |
Usage | core/domain/usage/ | Hectares tracking |
Next Steps
- Multi-Tenancy - Subdomain routing and tenant isolation
- Database Design - Schema organization and RLS policies
- Result Type Pattern - Error handling conventions