Key ideas:
- The is split into layers (Domain β Use Cases β Adapters β Frameworks).
- Dependencies point inward: outer layers (Next.js, Supabase) must depend on inner layers (use cases, domain), never the opposite.
- This makes your app testable, maintainable, and easier to change (swap DB, change UI) without rewriting business rules.

Figure demonstrate the Clean Architecture
So whatβs next ?
I will try to explain to myself and the end-reader step by step each layer and the way of implementing a this architecture in a NextJs application. How to do that ? - in each step i will try to set the theorictical concept and introduce the implementation.
Step 1 β Create the Domain layer
Theory:
In Clean Architecture, we start with the Domain because itβs the most stable part: the core concepts in this application ares Profile, WeightEntry, WellnessEntry.
βThe Domain layer defines the business entities and must remain independent from frameworks and databases.β
Practice:
We create our entities as plain TypeScript types under the path :
src/domaine/entitiesand defined the Profile, WeightEntry, and WellnessEntry entities
What the Domain layer should contain ?
β Entities (types/interfaces)
β Domain enums/unions (like WellnessMetric)
β No Supabase code
β No Next.js code
Whatβs next after defining entities ?
Time to define ports (repository interfaces) in the application layer, like:
This is where we say: βour app needs to store and read profiles/entries, but it doesnβt know how yet.β
Step 2 β Define repository ports (interfaces) in the Application layer
Theory:
In Clean Architecture, use cases must not depend on the database.
Instead, use cases depend on ports (interfaces) that describe what data operations they need.
Later, the Infrastructure layer (Supabase or SQLLite) will implement these interfaces.
Practice:
So weβll create repository interfaces in src/application/ports/.
src/application/ports/
profile.repository.ts
weight.repository.ts
wellness.repository.tsExample:
import { WeightEntry } from "@/src/domaine/entities/weight-entry.entity";
/**
* Data required to create a weight entry.
* The database generates the id.
*/
export type CreateWeightEntryInput = Omit<WeightEntry, "id">;
/**
* WeightRepository is a port used by use cases.
*/
export interface WeightRepository {
/**
* Creates a weight entry for a user.
*
* @param input - Weight entry fields (username, weight, date).
* @returns The created entry including its id.
*/
create(input: CreateWeightEntryInput): Promise<WeightEntry>;
/**
* Lists all weight entries for a given user.
*
* @param username - Owner username.
* @returns A list of weight entries (often sorted by date desc).
*/
list_by_username(username: string): Promise<WeightEntry[]>;
}What we achieved in step 2
- We created the contracts the app needs for persistence.
- Our future use cases will depend on these interfaces, not the database.
- Supabase or SQLLite becomes a βplug-inβ later.
Step 3 β Use Cases (Application Business Rules)
Theory:
In Clean Architecture, Use Cases represent the behavior of the system, it live in the Application layer and are responsible for enforcing business rules, not technical details.
They answer the question:
βWhat can the user do with this application?β
Examples:
- Create a profile
Key principles:
- Log todayβs weight
- Log sleep or steps
β A use case does not know about frameworks (Next.js, React).
β A use case does not know about databases (Supabase, SQL).
- A use case depends only on:
This separation keeps business logic stable and testable.
Practice:
In our application, we implemented several use cases to reflect real user actions.
Example:
Logging todayβs weight
Instead of writing βinsert into databaseβ, we created a use case:
This use case enforces the rule:
- A user can have only one weight entry per day
- If the user logs again the same day, the previous value is overridden
import type { WeightRepository } from "@/src/application/ports/weight.repository";
import { WeightEntry } from "@/src/domaine/entities/weight-entry.entity";
import { LogWeightEntryInput, logWeightSchema } from "@/src/application/validations/weight.schema";
/**
* Business rule:
* - Only ONE weight entry per user per day.
* - If the user logs weight again for the same date, we override (update).
*/
export class LogWeightEntryUseCase {
private readonly weightRepo: WeightRepository;
public constructor(weightRepo: WeightRepository) {
this.weightRepo = weightRepo;
}
/**
* @param input - username, weight, date
* @returns Created or updated WeightEntry.
* @throws {z.ZodError} If validation fails.
*/
public async execute(input: LogWeightEntryInput): Promise<WeightEntry> {
const validated = logWeightSchema.parse(input);
const normalizedUsername = validated.username.toLowerCase();
const existing = await this.weightRepo.find_by_username_and_date(
normalizedUsername,
validated.date
);
if (!existing) {
// First log of the day: create.
return this.weightRepo.create({
username: normalizedUsername,
weight: validated.weight,
date: validated.date,
});
}
// Second log (or more) of the same day: override (update).
return this.weightRepo.update_by_id(existing.id, { weight: validated.weight });
}
}
Why this approach scales ?
By using use cases:
- Business rules are defined in one place
- UI changes do not affect core logic
- Switching databases does not affect use cases
- Testing becomes straightforward
Use cases act as the heart of the application, coordinating behavior while remaining independent from external details.
As a simple Recup we will illustrate a diagram of what we have achived till now
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Domain β
β (Pure business types) β
β β
β Profile WeightEntry WellnessEntry WellnessMetric β
β β
βββββββββββββββββ²ββββββββββββββββββββββββββββββββββββββββββββββ
β (Use cases use domain entities)
β
βββββββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββ
β Application Layer β
β β
β ββββββββββββββββββββββββββββ βββββββββββββββββββββββββββββ β
β β Use Cases β β Ports β β
β β (Business behaviors) β β (Repository interfaces) β β
β β β β β β
β β - CreateProfileUseCase βββββΆβ ProfileRepository β β
β β - GetProfileByAuthUser βββββΆβ WeightRepository β β
β β - UpdateProfileUseCase βββββΆβ WellnessRepository β β
β β β β β β
β β - UpsertWeightEntry βββββΆβ (contract only, no DB) β β
β β - ListWeightEntries β βββββββββββββββββββββββββββββ β
β β - GetWeightSeries β β
β β β β
β β - UpsertWellnessEntry β β
β β - ListWellnessByMetric β β
β β - GetWellnessSeries β β
β ββββββββββββββββββββββββββββ β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Dependency direction (important):
Use Cases ββdepend onβββΆ Ports + Domain
Ports ββdepend onβββΆ Domain
Domain ββdepends onβββΆ nothing
Next is Infrastructure β we βplug inβ a real database without touching the use cases.
Step 4 β Infrastructure: Supabase Repository
Infrastructure adapters is where Clean Architecture starts to feel βrealβ: we connect the app to Supabase without changing any use case.
Theory
In Clean Architecture, the Infrastructure layer contains the code that talks to external systems like:
- databases (Supabase/Postgres)
- external APIs
- file storage
Practice
Goal
create a Supabase server client (SSR-safe)
Create Supabase implementations for the ports:
- SupabaseProfileRepository implements ProfileRepository
- SupabaseWeightRepository implements WeightRepository
- SupabaseWellnessRepository implements WellnessRepository
These adapters will:
- translate the port methods into Supabase queries
- enforce the βoverride per dayβ rule safely with DB constraints + upsert strategy
example :
Our Use Cases depend on a WeightRepository interface (port). The Infrastructure layer provides a Supabase adapter that implements this interface
Key points:
- The repository selects only the columns we need (explicit select(...)).
- It maps the raw Supabase payload into a Domain-safe WeightEntry.
- We keep the Supabase client request-scoped and injected (dependency injection).
This keeps the Application layer independent from Supabase details.
