Skip to main content

Chapter 6: Clean Architecture Principles

This chapter establishes the architectural foundation for the entire application. We adapt Clean Architecture principles for a React + Effect + Prisma stack, keeping what works and discarding what does not fit.

Why Architecture Matters

Without deliberate architecture, a codebase evolves organically into a tangle of dependencies where:

  • Changing the database requires modifying UI components
  • Testing business logic requires spinning up a full application
  • Adding a feature means understanding (and risking) unrelated features
  • New team members take weeks to become productive

Clean Architecture prevents this by enforcing one rule: dependencies point inward.

The Layers

┌─────────────────────────────────────────────────┐
│ Interface Layer │
│ (Routes, React Components, CLI) │
├─────────────────────────────────────────────────┤
│ Application Layer │
│ (Use Cases, Orchestration, DTOs) │
├─────────────────────────────────────────────────┤
│ Domain Layer │
│ (Entities, Value Objects, Domain Errors, │
│ Business Rules, Service Interfaces) │
├─────────────────────────────────────────────────┤
│ Infrastructure Layer │
│ (Database, HTTP, File System, External APIs, │
│ Implementations of Domain Interfaces) │
└─────────────────────────────────────────────────┘

Domain Layer — The Core

The domain layer contains your business logic. It has zero dependencies on external libraries or frameworks. No React imports. No Prisma imports. No Express imports. Only TypeScript and Effect (because Effect is our core language-level abstraction).

What lives here:

  • Entities: Core business objects (Project, Task, User)
  • Value Objects: Typed primitives (Email, ProjectId, Money)
  • Domain Errors: Typed error classes (ProjectNotFoundError, InsufficientPermissionsError)
  • Business Rules: Pure functions encoding domain logic (canUserEditProject, calculateTaskPriority)
  • Service Interfaces: Contracts that the infrastructure layer must implement
// domain/entities/project.ts
import { Schema } from "effect";

export const ProjectId = Schema.String.pipe(Schema.brand("ProjectId"));
export type ProjectId = typeof ProjectId.Type;

export const ProjectStatus = Schema.Literal("active", "archived", "completed");
export type ProjectStatus = typeof ProjectStatus.Type;

export const Project = Schema.Struct({
id: ProjectId,
name: Schema.String.pipe(Schema.minLength(1), Schema.maxLength(100)),
description: Schema.optional(Schema.String),
status: ProjectStatus,
organizationId: Schema.String.pipe(Schema.brand("OrganizationId")),
createdAt: Schema.Date,
updatedAt: Schema.Date,
});
export type Project = typeof Project.Type;
// domain/errors/project-errors.ts
import { Data } from "effect";

export class ProjectNotFoundError extends Data.TaggedError("ProjectNotFoundError")<{
readonly projectId: string;
}> {}

export class ProjectNameTakenError extends Data.TaggedError("ProjectNameTakenError")<{
readonly name: string;
readonly organizationId: string;
}> {}

export class InsufficientPermissionsError extends Data.TaggedError("InsufficientPermissionsError")<{
readonly userId: string;
readonly action: string;
readonly resource: string;
}> {}

Application Layer — Use Cases

The application layer orchestrates domain logic. It coordinates between multiple services to fulfill a user's intent. It depends only on the domain layer.

What lives here:

  • Use Cases: Single-responsibility operations (CreateProject, AssignTask, InviteMember)
  • DTOs: Data transfer objects that shape data for the interface layer
  • Ports: Interfaces defining what the application needs from infrastructure
// application/use-cases/create-project.ts
import { Effect } from "effect";
import type { Project } from "@/domain/entities/project";
import type { CreateProjectInput } from "@/domain/schemas/project-schemas";
import { ProjectRepository } from "@/domain/ports/project-repository";
import { AuthorizationService } from "@/domain/ports/authorization-service";
import { AuditService } from "@/domain/ports/audit-service";
import { ProjectNameTakenError, InsufficientPermissionsError } from "@/domain/errors/project-errors";

export const createProject = (
input: CreateProjectInput,
userId: string
) =>
Effect.gen(function* () {
// 1. Check permissions
const authService = yield* AuthorizationService;
yield* authService.requirePermission(userId, "project:create", input.organizationId);

// 2. Check business rules
const repo = yield* ProjectRepository;
const existing = yield* repo.findByName(input.name, input.organizationId);
if (existing) {
yield* new ProjectNameTakenError({
name: input.name,
organizationId: input.organizationId,
});
}

// 3. Create the project
const project = yield* repo.create({
...input,
status: "active",
});

// 4. Record audit trail
const audit = yield* AuditService;
yield* audit.record({
action: "project:created",
userId,
resourceId: project.id,
details: { name: project.name },
});

return project;
});

Notice: this use case does not know how permissions are checked, how the database works, or how audit logs are stored. It only knows the contracts (interfaces) defined in the domain layer.

Infrastructure Layer — Implementations

The infrastructure layer implements the interfaces defined by the domain. This is where Prisma, HTTP clients, file systems, and external API integrations live.

// infrastructure/repositories/prisma-project-repository.ts
import { Effect, Layer } from "effect";
import { PrismaClient } from "@/shared/lib/prisma";
import { ProjectRepository } from "@/domain/ports/project-repository";
import { ProjectNotFoundError } from "@/domain/errors/project-errors";

export const PrismaProjectRepository = Layer.effect(
ProjectRepository,
Effect.gen(function* () {
const prisma = yield* PrismaClient;

return {
findById: (id) =>
Effect.tryPromise({
try: () => prisma.project.findUnique({ where: { id } }),
catch: (error) => new DatabaseError({ cause: error }),
}).pipe(
Effect.flatMap((project) =>
project
? Effect.succeed(project)
: Effect.fail(new ProjectNotFoundError({ projectId: id }))
)
),

findByName: (name, organizationId) =>
Effect.tryPromise({
try: () =>
prisma.project.findFirst({
where: { name, organizationId },
}),
catch: (error) => new DatabaseError({ cause: error }),
}),

create: (data) =>
Effect.tryPromise({
try: () => prisma.project.create({ data }),
catch: (error) => new DatabaseError({ cause: error }),
}),
};
})
);

Interface Layer — Entry Points

The interface layer is where React components, API routes, and CLI commands live. It composes use cases with their infrastructure implementations and presents results to the user.

// In a route or component, the interface layer wires everything together
import { createProject } from "@/application/use-cases/create-project";
import { AppRuntime } from "@/shared/lib/effect-runtime";

function useCreateProject() {
return useMutation({
mutationFn: (input: CreateProjectInput) =>
AppRuntime.runPromise(
createProject(input, currentUserId)
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] });
},
});
}

The Dependency Rule

The most important rule of Clean Architecture: source code dependencies point inward only.

Interface → Application → Domain ← Infrastructure
  • The Domain layer knows nothing about the outside world
  • The Application layer knows about Domain but not Infrastructure
  • The Infrastructure layer knows about Domain (implements its interfaces)
  • The Interface layer knows about everything (it wires them together)

Infrastructure depends on Domain because it implements Domain's interfaces (ports). But Domain never imports from Infrastructure.

Mapping to Our Folder Structure

Here is how Clean Architecture layers map to the feature-based folder structure from Chapter 5:

features/projects/
├── domain/ # Domain Layer
│ ├── entities/
│ │ └── project.ts # Entity definitions (Effect Schema)
│ ├── errors/
│ │ └── project-errors.ts # Domain error types
│ ├── rules/
│ │ └── project-rules.ts # Pure business rule functions
│ └── ports/
│ └── project-repository.ts # Interface (Context.Tag)

├── application/ # Application Layer
│ └── use-cases/
│ ├── create-project.ts
│ ├── update-project.ts
│ └── archive-project.ts

├── infrastructure/ # Infrastructure Layer
│ └── repositories/
│ └── prisma-project-repository.ts

├── components/ # Interface Layer (UI)
│ ├── project-card.tsx
│ ├── project-list.tsx
│ └── project-form.tsx

├── hooks/ # Interface Layer (React hooks)
│ ├── use-projects.ts
│ └── use-project-mutations.ts

└── index.ts # Public API

When to Be Pragmatic

Clean Architecture is a guideline, not a religion. Here is when to bend the rules:

Small features can collapse layers

If a feature is a simple CRUD with no business rules, you do not need separate domain, application, and infrastructure directories:

// For simple features, a service file can do everything
// features/tags/services/tag-service.ts
export class TagService extends Context.Tag("TagService")<
TagService,
{
readonly list: () => Effect.Effect<Tag[], DatabaseError>;
readonly create: (name: string) => Effect.Effect<Tag, DatabaseError>;
readonly delete: (id: string) => Effect.Effect<void, DatabaseError>;
}
>() {}

Don't over-abstract early

Start with the simplest structure that respects the dependency rule. Add layers when complexity demands it:

  1. Start: Service + components (all in feature directory)
  2. Grow: Extract domain types and errors when business logic appears
  3. Scale: Introduce use cases when orchestration between multiple services is needed
  4. Mature: Add ports/adapters when you need multiple implementations (e.g., test mocks)

The 80/20 rule

80% of features are straightforward CRUD. Apply full Clean Architecture to the 20% with complex business logic. For the rest, a service layer with proper Effect error types is sufficient.

Summary

  • Domain layer contains pure business logic with no framework dependencies
  • Application layer orchestrates domain logic through use cases
  • Infrastructure layer implements domain interfaces with concrete technology (Prisma, HTTP, etc.)
  • Interface layer wires everything together and presents it to users
  • Dependencies point inward — the domain never depends on infrastructure
  • Be pragmatic — not every feature needs all four layers
  • Effect provides the glue: typed errors in the domain, service interfaces as Context.Tag, dependency injection through layers

This architecture makes the codebase testable (mock infrastructure, test domain logic in isolation), maintainable (changes are localized), and understandable (each layer has a clear responsibility).