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.
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
-
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
-
Leaky Abstractions
- Database concerns leak into business logic (SQL, ORM entities)
- Business logic becomes aware of persistence details
- Domain models often have database annotations
-
Circular Dependencies
- Business layer creates data access objects
- Data access returns domain models
- Creates tight coupling between layers
-
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:
- Dependency Inversion: Domain defines interfaces (ports), infrastructure implements them (adapters)
- Domain Core is Pure: No infrastructure dependencies, no framework code
- Testability: Domain can be tested in isolation with test doubles
- 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:
- Service Isolation: Each deployable unit stays independently shippable
- Internal Quality: Each service maintains clean internal architecture
- Technology Freedom: Each service can use different infrastructure
- Testability: Domain logic in each service is pure and testable
- 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
| Layer | Test Type | Speed | Infrastructure Needed | Coverage Target |
|---|
| Domain | Unit Tests | Milliseconds | None | 100% |
| Application | Unit Tests with Mocks | Milliseconds | None | 90%+ |
| Infrastructure | Integration Tests | Seconds | Database, Services | 70%+ |
| Presentation | Integration Tests | Seconds | Full stack | 60%+ |
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
- Microservices ≠ No Internal Structure: Each service needs clean internal architecture
- Hexagonal Within Services: Provides testability and maintainability per service
- Layer Discipline: Strict layer boundaries prevent architectural decay
- Independent Evolution: Each service can evolve its internals independently
- Consistent Patterns: Same layering approach across services aids understanding
Comparison Matrix
| Aspect | N-Tier | Hexagonal/Clean/Onion | Microservices |
|---|
| Scope | Internal organization | Internal organization | External distribution |
| Dependencies | Downward (UI→BLL→DAL) | Inward (Infrastructure→Domain) | Service-to-service via events |
| Coupling | Tight (layers depend on concrete implementations) | Loose (depends on interfaces) | Very loose (event-driven) |
| Testability | Hard (requires database) | Easy (pure domain logic) | Moderate (contract testing) |
| Technology Flexibility | Low (framework lock-in) | High (swappable adapters) | Very high (per-service choice) |
| Deployment Unit | Entire application | Entire application | Individual service |
| Scalability | All-or-nothing | All-or-nothing | Granular per service |
| Complexity | Low | Moderate | High |
| Best For | Simple CRUD apps | Complex business logic | Distributed 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.