Skip to main content
Understanding how to structure code within services is critical for maintainability and testability. This document compares traditional N-Tier architecture with modern layer-based patterns, and explains how layering works within microservices.
Key Insight: Layering is an internal concern (how code is organized within a service), while microservices is an external concern (how services are distributed). These patterns are complementary, not mutually exclusive.
How to read this page. The N-Tier, Hexagonal, Clean, and Onion sections below are conceptual / educational material that explains layering theory with illustrative TypeScript. Tuturuuu’s apps do not ship a formal hexagonal directory layout (no domain/, application/, or infrastructure/ folders). Each app is a conventional Next.js App Router app (app/, components/, lib/, utils/, features/) that talks to a shared Supabase project, with the newer backend logic moving into the Rust service in apps/backend. Treat the layered code samples as patterns to learn from, not as a map of the current codebase. The “Tuturuuu’s Implementation” section near the end describes the actual layout.
Active migration. apps/web (Next.js, port 7803) is being replaced by apps/tanstack-web (TanStack Start) plus apps/backend (Rust, port 7820). Backend layering increasingly lives in the Rust service. See TanStack Start And Rust Migration for the migration contract.
For a comprehensive comparison of all architectural patterns with detailed pros and cons, see Architectural Patterns Comparison.

Architectural Layering Patterns Compared

1. Traditional N-Tier Architecture

What Is N-Tier Architecture?

N-Tier (also called “N-Layer”) is a traditional architectural pattern that organizes code into horizontal layers, each responsible for a specific technical concern. The most common form is 3-tier architecture:

Characteristics of N-Tier

Dependencies Flow Downward:
  • Presentation → Business Logic → Data Access → Database
  • Each layer can only call the layer directly below it
  • Upper layers depend on concrete implementations in lower layers
Example (Traditional N-Tier):
// Presentation Layer
export async function GET(request: Request) {
  const workspaceService = new WorkspaceService();
  const workspaces = await workspaceService.getWorkspaces();

  return Response.json(workspaces);
}

// Business Logic Layer
export class WorkspaceService {
  async getWorkspaces() {
    // Business logic mixed with data access
    const dal = new WorkspaceDAL();
    const workspaces = await dal.findAll();

    // Validation happens here
    return workspaces.filter(w => w.isActive);
  }
}

// Data Access Layer
export class WorkspaceDAL {
  async findAll() {
    // Direct database coupling
    const { data } = await supabase
      .from('workspaces')
      .select('*');

    return data;
  }
}

Problems with Traditional N-Tier

  1. Tight Coupling to Infrastructure
    • Business logic depends directly on data access layer
    • Changing databases requires modifying business logic
    • Hard to test business rules without a database
  2. Leaky Abstractions
    • Database concerns leak into business logic (SQL, ORM entities)
    • Business logic becomes aware of persistence details
    • Domain models often have database annotations
  3. Circular Dependencies
    • Business layer creates data access objects
    • Data access returns domain models
    • Creates tight coupling between layers
  4. Technology Lock-In
    • Business logic married to specific ORM or database
    • Difficult to swap technologies
    • Framework dependencies throughout codebase
Example of the Problem:
// Business logic layer - TIGHTLY COUPLED to database
export class WorkspaceService {
  async createWorkspace(name: string, ownerId: string) {
    // PROBLEM: Business logic knows about Supabase
    const { data, error } = await supabase
      .from('workspaces')
      .insert({ name, owner_id: ownerId })
      .select()
      .single();

    if (error) throw error;

    // PROBLEM: Business validation after database insert
    if (data.name.length < 3) {
      // Too late - already in database!
      await supabase.from('workspaces').delete().eq('id', data.id);
      throw new Error('Name too short');
    }

    return data;
  }
}

2. Modern Layer-Based Architectures

Layer-based architectures (Hexagonal, Clean, Onion) solve N-Tier’s problems by inverting dependencies and organizing around business domains rather than technical layers.

Hexagonal Architecture (Ports & Adapters)

Key Principles:
  1. Dependency Inversion: Domain defines interfaces (ports), infrastructure implements them (adapters)
  2. Domain Core is Pure: No infrastructure dependencies, no framework code
  3. Testability: Domain can be tested in isolation with test doubles
  4. Technology Agnostic: Infrastructure can be swapped without changing business logic
Example (Hexagonal Architecture):
// ===== DOMAIN CORE (Pure Business Logic) =====

// Domain Entity - No infrastructure dependencies
export class Workspace {
  private constructor(
    public readonly id: string,
    private _name: string,
    private _ownerId: string,
    private _isActive: boolean,
    public readonly createdAt: Date
  ) {}

  static create(name: string, ownerId: string): Workspace {
    // Business rule: Name must be 3-50 characters
    if (name.length < 3 || name.length > 50) {
      throw new DomainError('Workspace name must be 3-50 characters');
    }

    return new Workspace(
      generateId(),
      name,
      ownerId,
      true,
      new Date()
    );
  }

  rename(newName: string): void {
    if (newName.length < 3 || newName.length > 50) {
      throw new DomainError('Workspace name must be 3-50 characters');
    }
    this._name = newName;
  }

  archive(): void {
    if (!this._isActive) {
      throw new DomainError('Workspace is already archived');
    }
    this._isActive = false;
  }

  get name(): string { return this._name; }
  get ownerId(): string { return this._ownerId; }
  get isActive(): boolean { return this._isActive; }
}

// Secondary Port - Interface defined by domain
export interface WorkspaceRepository {
  findById(id: string): Promise<Workspace | null>;
  findByOwnerId(ownerId: string): Promise<Workspace[]>;
  save(workspace: Workspace): Promise<void>;
  delete(id: string): Promise<void>;
}

// Domain Service - Pure business logic
export class WorkspaceService {
  constructor(
    private readonly repository: WorkspaceRepository  // Depends on interface
  ) {}

  async createWorkspace(name: string, ownerId: string): Promise<Workspace> {
    // Business validation happens BEFORE persistence
    const workspace = Workspace.create(name, ownerId);

    await this.repository.save(workspace);

    return workspace;
  }

  async archiveWorkspace(id: string): Promise<void> {
    const workspace = await this.repository.findById(id);
    if (!workspace) {
      throw new DomainError('Workspace not found');
    }

    // Domain method encapsulates business rule
    workspace.archive();

    await this.repository.save(workspace);
  }
}

// ===== INFRASTRUCTURE (Adapters) =====

// Secondary Adapter - Implements domain interface
export class SupabaseWorkspaceRepository implements WorkspaceRepository {
  constructor(private readonly supabase: SupabaseClient) {}

  async findById(id: string): Promise<Workspace | null> {
    const { data } = await this.supabase
      .from('workspaces')
      .select('*')
      .eq('id', id)
      .single();

    return data ? this.toDomain(data) : null;
  }

  async save(workspace: Workspace): Promise<void> {
    await this.supabase
      .from('workspaces')
      .upsert(this.toDatabase(workspace));
  }

  // Mapping between database schema and domain model
  private toDomain(row: any): Workspace {
    return new Workspace(
      row.id,
      row.name,
      row.owner_id,
      row.is_active,
      new Date(row.created_at)
    );
  }

  private toDatabase(workspace: Workspace): any {
    return {
      id: workspace.id,
      name: workspace.name,
      owner_id: workspace.ownerId,
      is_active: workspace.isActive,
      created_at: workspace.createdAt.toISOString()
    };
  }
}

// Primary Adapter - API endpoint
export async function POST(request: Request) {
  const body = await request.json();

  // Dependency injection - provide implementation.
  // Note: Tuturuuu's createClient() (@tuturuuu/supabase/next/server) is async.
  const repository = new SupabaseWorkspaceRepository(await createClient());
  const service = new WorkspaceService(repository);

  const workspace = await service.createWorkspace(
    body.name,
    body.ownerId
  );

  return Response.json({ workspace });
}

Benefits of Hexagonal Architecture

Domain Logic is Pure and Testable
// Test without any infrastructure
describe('Workspace', () => {
  it('enforces name length business rule', () => {
    expect(() => {
      Workspace.create('ab', 'owner-123');  // Too short
    }).toThrow('Workspace name must be 3-50 characters');
  });

  it('prevents archiving already archived workspace', () => {
    const workspace = Workspace.create('Test', 'owner-123');
    workspace.archive();

    expect(() => workspace.archive()).toThrow('Workspace is already archived');
  });
});
Technology Can Be Swapped Easily
// Switch from Supabase to Drizzle - domain unchanged
const repository = new DrizzleWorkspaceRepository(db);
const service = new WorkspaceService(repository);
// All business logic works identically
Fast, Reliable Tests
// In-memory test double - runs in milliseconds
class InMemoryWorkspaceRepository implements WorkspaceRepository {
  private workspaces = new Map<string, Workspace>();

  async findById(id: string) {
    return this.workspaces.get(id) || null;
  }

  async save(workspace: Workspace) {
    this.workspaces.set(workspace.id, workspace);
  }
}

// Test runs instantly, no database needed
const repo = new InMemoryWorkspaceRepository();
const service = new WorkspaceService(repo);
await service.createWorkspace('Test', 'owner-123');

3. Clean Architecture

Clean Architecture (by Robert C. Martin) is similar to Hexagonal but emphasizes concentric circles of dependencies. Key Rule: Dependencies can only point inward. Inner circles know nothing about outer circles. Example Structure (illustrative — these paths are not present in the repo):
// ===== INNERMOST: Enterprise Business Rules =====
// Illustrative: packages/types/src/domain/workspace.ts
export class Workspace {
  // Pure domain entity - no dependencies
}

// ===== APPLICATION LAYER: Use Cases =====
// Illustrative: src/application/use-cases/create-workspace.ts
export class CreateWorkspaceUseCase {
  constructor(
    private readonly repository: WorkspaceRepository,
    private readonly eventPublisher: EventPublisher
  ) {}

  async execute(command: CreateWorkspaceCommand): Promise<Workspace> {
    // Orchestrates domain entities and infrastructure
    const workspace = Workspace.create(command.name, command.ownerId);
    await this.repository.save(workspace);
    await this.eventPublisher.publish(new WorkspaceCreatedEvent(workspace));
    return workspace;
  }
}

// ===== INTERFACE ADAPTERS: Controllers =====
// Illustrative: src/app/api/workspaces/create/route.ts
export async function POST(request: Request) {
  const useCase = new CreateWorkspaceUseCase(repository, eventPublisher);
  const workspace = await useCase.execute(command);
  return Response.json(toDTO(workspace));
}

// ===== OUTERMOST: Frameworks & Drivers =====
// Infrastructure implementations (Supabase, Trigger.dev, etc.)

4. Onion Architecture

Onion Architecture is similar to Clean but visualizes layers as concentric circles with explicit layer names. All three patterns (Hexagonal, Clean, Onion) share the same core principle: Dependency Inversion to keep business logic pure and independent.

Layering Within Microservices

Microservices ≠ No Layering

Common Misconception: “Microservices replace layering.” Reality: Microservices is an external architectural pattern (how services are distributed across the network). Layering is an internal pattern (how code is organized within each service).

Illustrative: Hexagonal Architecture Within a Service

The directory tree below is an illustrative target showing how a service could be organized with strict hexagonal layers. It is not the current Tuturuuu layout — apps/web/src and apps/finance/src do not contain domain/, application/, or infrastructure/ folders. See Tuturuuu’s Implementation for the real structure.
# Aspirational hexagonal layout for a single service (not the current repo)
src/
├── domain/                    # Domain Core (Pure business logic)
│   ├── models/
│   │   └── workspace.ts       # Entities, Value Objects
│   └── services/
│       └── workspace-service.ts
├── application/               # Application Layer (Use Cases)
│   └── use-cases/
│       └── create-workspace.ts
├── infrastructure/            # Infrastructure (Adapters)
│   ├── repositories/
│   │   └── supabase-workspace-repository.ts
│   └── events/
│       └── trigger-publisher.ts
└── app/                       # Presentation (API Endpoints)
    └── api/workspaces/create/route.ts
Why a team might adopt this approach:
  1. Service Isolation: Each deployable unit stays independently shippable
  2. Internal Quality: Each service maintains clean internal architecture
  3. Technology Freedom: Each service can use different infrastructure
  4. Testability: Domain logic in each service is pure and testable
  5. Maintainability: Clear structure within each service
In practice, Tuturuuu keeps most code in conventional Next.js folders and pushes heavier backend logic into the Rust service (apps/backend) rather than introducing a formal hexagonal folder hierarchy inside each frontend app.

Layer-Based Architecture Inside Microservices (Deep Dive)

While microservices define how services communicate externally, the internal structure of each service is equally important. This section provides a comprehensive guide to implementing layer-based architecture within microservices.

Why Layering Matters in Microservices

Many teams make the mistake of thinking: “We have microservices, so we don’t need internal structure.” This leads to:
  • Business logic mixed with HTTP handling
  • Database queries scattered throughout the codebase
  • Difficult-to-test services
  • Technology coupling within services
The reality: Each microservice should have clean internal architecture to maintain quality as services grow.

Complete Layer-by-Layer Breakdown

Layer 1: Domain Core (Innermost)

Purpose: Contains pure business logic with zero external dependencies. Contents:
  • Entities (business objects with identity)
  • Value Objects (immutable business concepts)
  • Domain Services (complex business operations)
  • Business Rules (validation, constraints)
  • Domain Events (things that happened)
Example - Complete Domain Layer (illustrative path; the repo does not contain a src/domain/ folder):
// Illustrative: src/domain/models/workspace.ts
// Pure business entity - no infrastructure dependencies

export class Workspace {
  private constructor(
    public readonly id: string,
    private _name: string,
    private _ownerId: string,
    private _isActive: boolean,
    private _memberLimit: number,
    public readonly createdAt: Date
  ) {}

  // Factory method - enforces business rules
  static create(name: string, ownerId: string, memberLimit: number = 10): Workspace {
    // Business Rule: Name length
    if (name.length < 3 || name.length > 50) {
      throw new DomainError('Workspace name must be 3-50 characters');
    }

    // Business Rule: Member limit
    if (memberLimit < 1 || memberLimit > 1000) {
      throw new DomainError('Member limit must be 1-1000');
    }

    return new Workspace(
      generateId(),
      name,
      ownerId,
      true,
      memberLimit,
      new Date()
    );
  }

  // Domain behavior - business operations
  rename(newName: string): void {
    if (newName.length < 3 || newName.length > 50) {
      throw new DomainError('Workspace name must be 3-50 characters');
    }

    if (newName === this._name) {
      throw new DomainError('New name must be different');
    }

    this._name = newName;
  }

  archive(): void {
    if (!this._isActive) {
      throw new DomainError('Workspace is already archived');
    }
    this._isActive = false;
  }

  reactivate(): void {
    if (this._isActive) {
      throw new DomainError('Workspace is already active');
    }
    this._isActive = true;
  }

  // Business rule encapsulated in method
  canAddMember(currentMemberCount: number): boolean {
    return currentMemberCount < this._memberLimit;
  }

  // Getters
  get name(): string { return this._name; }
  get ownerId(): string { return this._ownerId; }
  get isActive(): boolean { return this._isActive; }
  get memberLimit(): number { return this._memberLimit; }
}

// Illustrative: src/domain/value-objects/email.ts
// Value Object - immutable, validated business concept
export class Email {
  private constructor(private readonly value: string) {}

  static create(email: string): Email {
    // Business rule: Valid email format
    if (!this.isValidEmail(email)) {
      throw new DomainError('Invalid email format');
    }
    return new Email(email.toLowerCase());
  }

  private static isValidEmail(email: string): boolean {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
  }

  toString(): string {
    return this.value;
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }
}
Testing Domain Layer:
// Domain tests - no infrastructure needed
describe('Workspace Domain', () => {
  describe('create', () => {
    it('creates valid workspace', () => {
      const workspace = Workspace.create('My Workspace', 'owner-123', 50);

      expect(workspace.name).toBe('My Workspace');
      expect(workspace.ownerId).toBe('owner-123');
      expect(workspace.memberLimit).toBe(50);
      expect(workspace.isActive).toBe(true);
    });

    it('rejects short names', () => {
      expect(() => Workspace.create('ab', 'owner-123'))
        .toThrow('Workspace name must be 3-50 characters');
    });

    it('rejects invalid member limits', () => {
      expect(() => Workspace.create('Test', 'owner-123', 0))
        .toThrow('Member limit must be 1-1000');
    });
  });

  describe('rename', () => {
    it('allows renaming', () => {
      const workspace = Workspace.create('Test', 'owner-123');
      workspace.rename('New Name');
      expect(workspace.name).toBe('New Name');
    });

    it('rejects renaming to same name', () => {
      const workspace = Workspace.create('Test', 'owner-123');
      expect(() => workspace.rename('Test'))
        .toThrow('New name must be different');
    });
  });

  describe('canAddMember', () => {
    it('allows adding when under limit', () => {
      const workspace = Workspace.create('Test', 'owner-123', 10);
      expect(workspace.canAddMember(5)).toBe(true);
    });

    it('prevents adding when at limit', () => {
      const workspace = Workspace.create('Test', 'owner-123', 10);
      expect(workspace.canAddMember(10)).toBe(false);
    });
  });
});

// Tests run in milliseconds - no database, no network

Layer 2: Application Layer (Use Cases)

Purpose: Orchestrates domain entities and infrastructure to fulfill application workflows. Contents:
  • Use Cases (application workflows)
  • Commands (input data structures)
  • Application Services (workflow orchestration)
  • DTOs (data transfer objects)
Example - Complete Application Layer:
// Illustrative: src/application/commands/create-workspace.command.ts
export interface CreateWorkspaceCommand {
  name: string;
  ownerId: string;
  memberLimit?: number;
}

// Illustrative: src/application/use-cases/create-workspace.usecase.ts
import { Workspace } from '@/domain/models/workspace';
import { WorkspaceRepository } from '@/domain/interfaces/workspace-repository';
import { EventPublisher } from '@/domain/interfaces/event-publisher';
import { WorkspaceCreatedEvent } from '@/domain/events/workspace-created.event';

export class CreateWorkspaceUseCase {
  constructor(
    private readonly repository: WorkspaceRepository,
    private readonly eventPublisher: EventPublisher,
    private readonly logger: Logger
  ) {}

  async execute(command: CreateWorkspaceCommand): Promise<Workspace> {
    this.logger.info('Creating workspace', { command });

    // 1. Create domain entity (business logic)
    const workspace = Workspace.create(
      command.name,
      command.ownerId,
      command.memberLimit || 10
    );

    // 2. Persist (infrastructure)
    await this.repository.save(workspace);
    this.logger.info('Workspace persisted', { workspaceId: workspace.id });

    // 3. Publish domain event (infrastructure)
    await this.eventPublisher.publish(
      new WorkspaceCreatedEvent(
        workspace.id,
        workspace.ownerId,
        workspace.name,
        new Date()
      )
    );
    this.logger.info('Workspace created event published', { workspaceId: workspace.id });

    return workspace;
  }
}

// Illustrative: src/application/use-cases/archive-workspace.usecase.ts
export class ArchiveWorkspaceUseCase {
  constructor(
    private readonly repository: WorkspaceRepository,
    private readonly eventPublisher: EventPublisher
  ) {}

  async execute(workspaceId: string, userId: string): Promise<void> {
    // 1. Load entity
    const workspace = await this.repository.findById(workspaceId);
    if (!workspace) {
      throw new ApplicationError('Workspace not found');
    }

    // 2. Check authorization (application-level concern)
    if (workspace.ownerId !== userId) {
      throw new UnauthorizedError('Only owner can archive workspace');
    }

    // 3. Execute domain operation
    workspace.archive();

    // 4. Persist changes
    await this.repository.save(workspace);

    // 5. Publish event
    await this.eventPublisher.publish(
      new WorkspaceArchivedEvent(workspace.id, userId, new Date())
    );
  }
}
Testing Application Layer:
// Use case tests - with test doubles
describe('CreateWorkspaceUseCase', () => {
  let useCase: CreateWorkspaceUseCase;
  let mockRepository: MockWorkspaceRepository;
  let mockEventPublisher: MockEventPublisher;
  let mockLogger: MockLogger;

  beforeEach(() => {
    mockRepository = new MockWorkspaceRepository();
    mockEventPublisher = new MockEventPublisher();
    mockLogger = new MockLogger();
    useCase = new CreateWorkspaceUseCase(
      mockRepository,
      mockEventPublisher,
      mockLogger
    );
  });

  it('creates and persists workspace', async () => {
    const command = {
      name: 'Test Workspace',
      ownerId: 'owner-123',
      memberLimit: 50
    };

    const workspace = await useCase.execute(command);

    // Verify workspace created correctly
    expect(workspace.name).toBe('Test Workspace');
    expect(workspace.ownerId).toBe('owner-123');

    // Verify repository called
    expect(mockRepository.saved).toHaveLength(1);
    expect(mockRepository.saved[0]).toBe(workspace);

    // Verify event published
    expect(mockEventPublisher.published).toHaveLength(1);
    expect(mockEventPublisher.published[0]).toBeInstanceOf(WorkspaceCreatedEvent);
  });

  it('propagates domain errors', async () => {
    const command = {
      name: 'ab', // Too short
      ownerId: 'owner-123'
    };

    await expect(useCase.execute(command))
      .rejects
      .toThrow('Workspace name must be 3-50 characters');
  });
});

Layer 3: Infrastructure Layer (Adapters)

Purpose: Implements infrastructure concerns and connects to external systems. Contents:
  • Repository Implementations (database access)
  • Event Publishers (message brokers)
  • External Service Clients (HTTP, gRPC)
  • Caching Implementations
  • File Storage Implementations
Example - Complete Infrastructure Layer:
// Illustrative: src/infrastructure/repositories/supabase-workspace.repository.ts
import { Workspace } from '@/domain/models/workspace';
import { WorkspaceRepository } from '@/domain/interfaces/workspace-repository';
import { SupabaseClient } from '@supabase/supabase-js';

export class SupabaseWorkspaceRepository implements WorkspaceRepository {
  constructor(private readonly supabase: SupabaseClient) {}

  async findById(id: string): Promise<Workspace | null> {
    const { data, error } = await this.supabase
      .from('workspaces')
      .select('*')
      .eq('id', id)
      .single();

    if (error || !data) return null;

    return this.toDomain(data);
  }

  async findByOwnerId(ownerId: string): Promise<Workspace[]> {
    const { data, error } = await this.supabase
      .from('workspaces')
      .select('*')
      .eq('owner_id', ownerId)
      .order('created_at', { ascending: false });

    if (error || !data) return [];

    return data.map(row => this.toDomain(row));
  }

  async save(workspace: Workspace): Promise<void> {
    const dbModel = this.toDatabase(workspace);

    const { error } = await this.supabase
      .from('workspaces')
      .upsert(dbModel);

    if (error) {
      throw new InfrastructureError(`Failed to save workspace: ${error.message}`);
    }
  }

  async delete(id: string): Promise<void> {
    const { error } = await this.supabase
      .from('workspaces')
      .delete()
      .eq('id', id);

    if (error) {
      throw new InfrastructureError(`Failed to delete workspace: ${error.message}`);
    }
  }

  // Mapping: Database schema → Domain model
  private toDomain(row: any): Workspace {
    // Use a reflection or factory method to create Workspace
    // bypassing constructor if needed for reconstruction
    return Object.assign(
      Object.create(Workspace.prototype),
      {
        id: row.id,
        _name: row.name,
        _ownerId: row.owner_id,
        _isActive: row.is_active,
        _memberLimit: row.member_limit,
        createdAt: new Date(row.created_at)
      }
    );
  }

  // Mapping: Domain model → Database schema
  private toDatabase(workspace: Workspace): any {
    return {
      id: workspace.id,
      name: workspace.name,
      owner_id: workspace.ownerId,
      is_active: workspace.isActive,
      member_limit: workspace.memberLimit,
      created_at: workspace.createdAt.toISOString()
    };
  }
}

// Illustrative: src/infrastructure/events/trigger-publisher.ts
//
// Tuturuuu uses Trigger.dev v4. Background work is modelled as `task()`
// definitions (in packages/trigger/src) that you enqueue with
// `myTask.trigger(payload)` — there is no v2-style `trigger.event(...)`,
// `client.defineJob`, or `eventTrigger`. The real tasks live in
// packages/trigger/src (e.g. scheduleTask, unifiedScheduleTask).
import { EventPublisher } from '@/domain/interfaces/event-publisher';
import { DomainEvent } from '@/domain/events/domain-event';
import { workspaceCreatedTask } from '@tuturuuu/trigger';

export class TriggerEventPublisher implements EventPublisher {
  async publish(event: DomainEvent): Promise<void> {
    // Enqueue a Trigger.dev v4 task run for the event.
    await workspaceCreatedTask.trigger({
      eventName: event.eventName,
      eventId: event.eventId,
      occurredAt: event.occurredAt.toISOString(),
      ...event.payload,
    });
  }

  async publishMany(events: DomainEvent[]): Promise<void> {
    await Promise.all(events.map((event) => this.publish(event)));
  }
}
For reference, the matching Trigger.dev v4 task definition looks like the real tasks in packages/trigger/src:
// packages/trigger/src/<your-task>.ts (Trigger.dev v4 task() API)
import { task } from '@trigger.dev/sdk/v3';

export const workspaceCreatedTask = task({
  id: 'workspace-created',
  queue: { concurrencyLimit: 10 },
  run: async (payload: {
    eventName: string;
    eventId: string;
    occurredAt: string;
    [key: string]: unknown;
  }) => {
    // ...react to the event (notifications, projections, etc.)
    return { ok: true };
  },
});

Layer 4: Presentation Layer (API/UI)

Purpose: Handles HTTP requests, validates input, and returns responses. Contents:
  • API Route Handlers
  • Request/Response DTOs
  • Input Validation
  • Authentication/Authorization
  • HTTP Status Codes
Example - Complete Presentation Layer:
// Illustrative: src/app/api/workspaces/create/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { CreateWorkspaceUseCase } from '@/application/use-cases/create-workspace.usecase';
import { SupabaseWorkspaceRepository } from '@/infrastructure/repositories/supabase-workspace.repository';
import { TriggerEventPublisher } from '@/infrastructure/events/trigger-publisher';
// In Tuturuuu, createClient() from @tuturuuu/supabase/next/server is ASYNC.
import { createClient } from '@tuturuuu/supabase/next/server';

// Request validation schema
const CreateWorkspaceSchema = z.object({
  name: z.string().min(3).max(50),
  memberLimit: z.number().min(1).max(1000).optional()
});

export async function POST(request: NextRequest) {
  try {
    // 1. Authentication (await the async client factory)
    const supabase = await createClient();
    const { data: { session } } = await supabase.auth.getSession();

    if (!session) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }

    // 2. Parse and validate request body
    const body = await request.json();
    const validation = CreateWorkspaceSchema.safeParse(body);

    if (!validation.success) {
      return NextResponse.json(
        { error: 'Invalid request', details: validation.error.issues },
        { status: 400 }
      );
    }

    // 3. Dependency injection - wire up layers
    const repository = new SupabaseWorkspaceRepository(supabase);
    const eventPublisher = new TriggerEventPublisher();
    const logger = console; // Or proper logger

    const useCase = new CreateWorkspaceUseCase(
      repository,
      eventPublisher,
      logger
    );

    // 4. Execute use case
    const workspace = await useCase.execute({
      name: validation.data.name,
      ownerId: session.user.id,
      memberLimit: validation.data.memberLimit
    });

    // 5. Return response
    return NextResponse.json(
      {
        workspace: {
          id: workspace.id,
          name: workspace.name,
          ownerId: workspace.ownerId,
          isActive: workspace.isActive,
          memberLimit: workspace.memberLimit,
          createdAt: workspace.createdAt.toISOString()
        }
      },
      { status: 201 }
    );

  } catch (error) {
    // Error handling
    if (error instanceof DomainError) {
      return NextResponse.json(
        { error: error.message },
        { status: 400 }
      );
    }

    if (error instanceof UnauthorizedError) {
      return NextResponse.json(
        { error: error.message },
        { status: 403 }
      );
    }

    console.error('Unexpected error:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

Layer Communication Patterns

Testing Strategy Per Layer

LayerTest TypeSpeedInfrastructure NeededCoverage Target
DomainUnit TestsMillisecondsNone100%
ApplicationUnit Tests with MocksMillisecondsNone90%+
InfrastructureIntegration TestsSecondsDatabase, Services70%+
PresentationIntegration TestsSecondsFull stack60%+

Comparison: N-Tier vs Hexagonal in Microservices

N-Tier within Microservice:
  • ❌ Business logic coupled to database
  • ❌ Hard to test without infrastructure
  • ❌ Technology lock-in within service
  • ✅ Simpler for trivial services
Hexagonal within Microservice:
  • ✅ Business logic is pure and testable
  • ✅ Technology can be swapped per service
  • ✅ High test coverage achievable
  • ❌ More code and structure needed

Deployment Considerations

Each app deploys independently. Tuturuuu does not expose bun deploy:web / bun deploy:finance / bun deploy:calendar scripts — deployment is driven by CI/CD (GitHub Actions) and Docker images, and each app’s package.json ships conventional dev, build, start, and test scripts (for example apps/web builds with next build --turbopack). Verify the current scripts in each app’s package.json before quoting commands.
# Build / run a single app locally (real scripts in apps/web/package.json)
$ cd apps/web && bun run build   # next build --turbopack
$ cd apps/web && bun start       # next start on port 7803

# Each app is a conventional Next.js App Router app sharing one Supabase
# project; heavier backend logic increasingly lives in apps/backend (Rust).

Key Takeaways

  1. Microservices ≠ No Internal Structure: Each service needs clean internal architecture
  2. Hexagonal Within Services: Provides testability and maintainability per service
  3. Layer Discipline: Strict layer boundaries prevent architectural decay
  4. Independent Evolution: Each service can evolve its internals independently
  5. Consistent Patterns: Same layering approach across services aids understanding

Comparison Matrix

AspectN-TierHexagonal/Clean/OnionMicroservices
ScopeInternal organizationInternal organizationExternal distribution
DependenciesDownward (UI→BLL→DAL)Inward (Infrastructure→Domain)Service-to-service via events
CouplingTight (layers depend on concrete implementations)Loose (depends on interfaces)Very loose (event-driven)
TestabilityHard (requires database)Easy (pure domain logic)Moderate (contract testing)
Technology FlexibilityLow (framework lock-in)High (swappable adapters)Very high (per-service choice)
Deployment UnitEntire applicationEntire applicationIndividual service
ScalabilityAll-or-nothingAll-or-nothingGranular per service
ComplexityLowModerateHigh
Best ForSimple CRUD appsComplex business logicDistributed systems, large teams

When to Use Each Pattern

Use N-Tier When:

  • ✅ Building simple CRUD applications
  • ✅ Team is unfamiliar with DDD/Hexagonal concepts
  • ✅ Rapid prototyping with acceptable technical debt
  • ✅ Application will remain small (<10k LOC)

Use Hexagonal/Clean/Onion When:

  • ✅ Complex business logic requires protection
  • ✅ Long-term maintainability is critical
  • ✅ Need to swap infrastructure components
  • ✅ High test coverage is required
  • ✅ Domain experts are involved in development

Use Microservices When:

  • ✅ Multiple teams working on different domains
  • ✅ Need independent deployment and scaling
  • ✅ Different parts of system have different technology needs
  • ✅ Can handle distributed system complexity
  • ✅ Have DevOps maturity for service orchestration

Tuturuuu’s Pragmatic Choice

Tuturuuu favors pragmatism over textbook purity:
  • Multiple deployable apps (apps/web, apps/finance, apps/calendar, the Rust apps/backend, …) for organizational agility and independent deployment.
  • Conventional Next.js App Router structure inside each frontend app (app/, components/, lib/, utils/, features/) rather than a formal hexagonal domain/application/infrastructure hierarchy. Shared logic lives in packages/*; cross-app data access goes through packages/internal-api helpers and REST /api/v1 routes.
  • Heavier backend logic moving into the Rust service (apps/backend), where layering and typed domain modules increasingly live as part of the TanStack + Rust migration.
The hexagonal samples above remain useful for reasoning about boundaries and testability, even though the repository does not enforce that exact folder shape.

Evolution Path

Many systems evolve through these patterns: Where Tuturuuu sits today. Rather than formally adopting “Step 3” (microservices with strict hexagonal layering), Tuturuuu runs several conventional Next.js App Router apps backed by a shared Supabase project, with shared logic in packages/* and an emerging Rust backend (apps/backend). The practical trajectory is toward the TanStack Start frontend (apps/tanstack-web) plus the Rust backend rather than toward per-app hexagonal folder hierarchies — see the TanStack + Rust migration. Treat the steps above as a conceptual maturity model, not a literal description of the repository.

Anti-Patterns to Avoid

❌ Hexagonal Architecture Without Discipline

// BAD: "Hexagonal" in name only
export class WorkspaceService {
  constructor(private readonly repository: WorkspaceRepository) {}

  async createWorkspace(name: string, ownerId: string) {
    // WRONG: Direct Supabase usage bypasses repository
    const { data } = await supabase.from('workspaces').insert({ name, owner_id: ownerId });
    return data;
  }
}
// This defeats the purpose of Hexagonal Architecture

❌ Distributed Monolith

// BAD: Microservices with tight coupling
// Service A
export async function createWorkspace(data: CreateWorkspaceData) {
  const workspace = await db.insert(data);

  // WRONG: Synchronous HTTP call to Service B
  await fetch('http://service-b/setup', {
    method: 'POST',
    body: JSON.stringify({ workspaceId: workspace.id })
  });

  return workspace;
}
// This is a distributed monolith, not microservices

❌ No Layering in Microservices

// BAD: API route with all logic mixed together
export async function POST(request: Request) {
  const body = await request.json();

  // Validation
  if (body.name.length < 3) return Response.json({ error: 'Too short' }, { status: 400 });

  // Database
  const { data } = await supabase.from('workspaces').insert(body);

  // Events (Trigger.dev v4: enqueue a task run, not v2 trigger.event(...))
  await workspaceCreatedTask.trigger({ ...data });

  return Response.json(data);
}
// No separation of concerns, hard to test, hard to maintain

Tuturuuu’s Implementation

Actual Project Structure

This is the real repository layout (verified against the codebase). Apps are conventional Next.js App Router apps — there are no domain/, application/, or infrastructure/ folders.
tuturuuu/
├── apps/                          # Independently deployable apps
│   ├── web/                       # Main platform (Next.js, port 7803)
│   │   └── src/
│   │       ├── app/               # App Router routes + /api endpoints
│   │       ├── components/
│   │       ├── features/          # Feature modules (forms, reports, ...)
│   │       ├── hooks/
│   │       ├── lib/
│   │       └── utils/
│   ├── finance/                   # Finance app (Next.js)
│   │   └── src/                   # app/, components/, lib/, utils/, ...
│   ├── calendar/                  # Calendar app (Next.js)
│   │   └── src/                   # app/, components/, lib/, constants/, ...
│   ├── tanstack-web/              # TanStack Start replacement for apps/web
│   └── backend/                   # Rust backend (port 7820)
│       └── src/                   # aurora.rs, inventory.rs, lib.rs, ...
└── packages/                      # Shared libraries (workspace:*)
    ├── types/                     # Shared types (DB types in @tuturuuu/types/db)
    ├── ui/                        # Shared UI components
    ├── supabase/                  # Shared Supabase client factories
    ├── internal-api/              # Cross-app data access helpers
    └── trigger/                   # Trigger.dev v4 task() definitions
Where backend layering lives. Newer/heavier backend logic is implemented in the Rust service (apps/backend/src, e.g. aurora.rs, inventory.rs, onboarding_progress.rs) rather than as a layered TypeScript hierarchy inside each frontend app. Frontends reach shared app data through packages/internal-api and REST /api/v1 routes. (A repo guard, check-tanstack-api-access, forbids apps/tanstack-web/src from calling /trpc — the tRPC surface in apps/web/src/trpc is a stub, not the product data path.)

How the Real Flow Maps to These Layers

You can still reason about a real Tuturuuu request in layered terms, even though the folders are flat:
  • Presentation — an App Router route handler in apps/web/src/app/api/... (or a TanStack Start route in apps/tanstack-web).
  • Application / orchestration — helpers in lib/, features/, packages/internal-api, or background task() runs in packages/trigger.
  • Domain rules — validation and business logic colocated with the feature (or enforced in the Rust backend).
  • Infrastructure — the shared Supabase client from @tuturuuu/supabase/next/server and the Rust backend’s data access.
// Real shape: an App Router API route in apps/web/src/app/api/...
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@tuturuuu/supabase/next/server';
import { workspaceCreatedTask } from '@tuturuuu/trigger';

export async function POST(request: NextRequest) {
  // createClient() is ASYNC in Tuturuuu — await it.
  const supabase = await createClient();
  const body = await request.json();

  // Presentation-level validation
  if (typeof body.name !== 'string' || body.name.length < 3) {
    return NextResponse.json({ error: 'Name too short' }, { status: 400 });
  }

  // Infrastructure: shared Supabase project
  const { data, error } = await supabase
    .from('workspaces')
    .insert({ name: body.name, owner_id: body.ownerId })
    .select()
    .single();

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }

  // Background work via Trigger.dev v4 task()
  await workspaceCreatedTask.trigger({ workspaceId: data.id });

  return NextResponse.json({ workspace: data }, { status: 201 });
}
The layered patterns earlier in this page are a lens for keeping responsibilities clear; the snippet above shows what that looks like in the conventional structure Tuturuuu actually ships.