Global WatchGlobal Watch Docs
Architecture

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 implementations

Domain 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 of setName()
  • 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 measurements
  • Email - Validated email addresses
  • Money - Currency amounts
  • Slug - 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:

TypePurposeSide Effects
CommandWrite operationsYes
QueryRead operationsNo

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

EntityLocationKey Behavior
Projectcore/domain/project/calculateArea(), archive(), restore()
Accountcore/domain/account/Account management
Assetcore/domain/asset/Geolocated items
Usagecore/domain/usage/Hectares tracking

Next Steps

On this page