TypeScript Clean Architecture: Core, Adapters, and Infra

Weak architecture blocks maintenance, testing, and change. This Clean Architecture derivation for TypeScript backends separates Core, Adapters, and Infra — with dependencies pointing inward.

Software development changes all the time. Weak architecture becomes expensive maintenance, slow features, hard tests, and bugs that are hard to isolate. It is worth investing in a structure that supports evolution without rewriting the system at every business pressure.

A bit of history

Clean Architecture is the name Robert C. Martin (Uncle Bob) gave, in 2012, in the book Clean Architecture: A Craftsman’s Guide to Software Structure and Design. The proposal avoids the rigidity of architectures coupled to framework and database: the core stays stable; external details change. The idea draws from DDD, SOLID, Onion Architecture, and Hexagonal Architecture.

General proposal

This article describes Clean Architecture and a practical derivation for TypeScript backends: three layers — Core, Adapters, and Infra.

  • Core — business rules and domain entities. Innermost layer.
  • Infra — external connections: concrete repositories, REST controllers, DI modules, framework boilerplate.
  • Adapters — mediation in both directions. A controller does not call a “raw” usecase: it goes through a service. A usecase does not talk to the database: it talks to a protocol that an adapter (repository, connector, handler) implements.

Each layer has different capabilities and constraints; SOLID weighs more in Core. It works for HTTP CRUD and for systems with several frameworks and channels.

Concrete benefits: clear responsibilities (reading and maintenance), flexibility to swap plugins without rewriting rules, and isolated tests per layer.

Layer guide

Example: user CRUD over REST with NestJS. Installation details are out of scope. Core-to-infra writing (inside out).

Core

In the classic design, domain and entities sit very close. Here they form the Core: everything the business rule is — features and domain representations.

In the example, the main entity is User (id, name), in core/entities.

Entities

// core/entities/UserEntity.ts
export interface UserEntityProps {
  id?: string;
  name: string;
}

export class UserEntity {
  constructor(private readonly props: UserEntityProps) {}

  get id(): string {
    return this.props.id ?? "";
  }

  get name(): string {
    return this.props.name;
  }
}

The entity receives typed props and exposes getters. It depends on an interface any transfer DTO can satisfy later.

Features and usecases

CRUD needs create, fetch, update, and remove. In Core, each usecase implements a contract (feature) with a single public method — aligned with Liskov, open/closed, interface segregation, and single responsibility. The usecase does not access the database: it knows protocols describing the external action (dependency inversion).

Registration: name is required; if it already exists, error; otherwise return UserEntity.

  • contract CreateUser
  • implementation CreateUserUsecase

In TypeScript, an abstract class with abstract methods works as both contract and value — useful for DI (const createUserSymbol = CreateUser):

// core/features/CreateUser.ts
export abstract class CreateUser {
  abstract execute(name: string): Promise<UserEntity>;
}
// core/usecases/CreateUserUsecase.ts
export class CreateUserUsecase implements CreateUser {
  constructor(
    private readonly createUserProtocol: CreateUserProtocol,
    private readonly getByNameProtocol: GetUserByNameProtocol,
  ) {}

  async execute(name: string): Promise<UserEntity> {
    const existsName = await this.getByNameProtocol.getByName(name);

    if (existsName) {
      throw new UserAlreadyExistsException(
        `the name ${name} already exists`,
      );
    }

    return this.createUserProtocol.register(name);
  }
}

The usecase defines what (validate name, register). It does not define how to fetch or persist. The rule stays independent of lib, framework, and database.

Watch out: a usecase that only delegates to the protocol without validating may be pushing business rules into the adapter. In CreateUserUsecase, the duplicate-name check is Core’s obligation.

Exceptions

UserAlreadyExistsException belongs to Core: an invalid rule flow is also a rule. Each failure mapped to a known exception helps maintenance. Base with code (later becomes HTTP status at the edge):

// core/exceptions/IBaseException.ts
export abstract class IBaseException extends Error {
  code: number;

  constructor(message: string) {
    super(message);
  }
}
// core/exceptions/UserAlreadyExistsException.ts
export class UserAlreadyExistsException extends IBaseException {
  constructor(message?: string) {
    super(message ?? "User already exists");
    this.code = 400;
  }
}

The usecase throws exceptions; it does not handle them. Mapping unknown type → known type belongs in adapter or infra.

Protocols

CreateUserProtocol and GetUserByNameProtocol are contracts for external-device access. A protocol exists to inform or trigger external action — not to process business rules. Preference: one public method per protocol.

// core/protocols/CreateUserProtocol.ts
export abstract class CreateUserProtocol {
  abstract register(name: string): Promise<UserEntity>;
}
// core/protocols/GetUserByNameProtocol.ts
export abstract class GetUserByNameProtocol {
  abstract getByName(name: string): Promise<UserEntity | null>;
}

Core is the center; the adaptation layer connects the rest.

Adapter

Adapters control bidirectional traffic: external → rule and rule → external. They adapt objects, parameters, and exceptions — the same spirit as the Adapter pattern.

Two groups:

  1. Called by Core — implement at least one protocol.
  2. Called by Infra — generally services.

Connectors, handlers, and repositories

Classes implementing protocols. Each adapts one external device (ORM, HTTP client, queue, filesystem).

Naming convention:

  • Repositories — protocol tied to a database (familiar vocabulary).
  • Connectors — return data without being a “table” (e.g. ClientHttpFetchConnector, ClientHttpAxiosConnector).
  • Handlers — process without synchronous return (e.g. publishing to Kafka).

Other names are valid; the criterion is one adapter per device.

In the CRUD, only repository (mock):

// adapters/repositories/UsersMockRepository.ts
export class UsersMockRepository
  implements
    GetUserByIdProtocol,
    GetUserByNameProtocol,
    CreateUserProtocol,
    UpdateUserProtocol,
    DeleteUserProtocol
{
  private db: DbConnector;

  constructor() {
    this.db = mockDbConnector;
  }

  async getById(id: string): Promise<UserEntity> {
    return this.db.users.getById(id);
  }

  async getByName(name: string): Promise<UserEntity | null> {
    return this.db.users.getByName(name);
  }

  async register(name: string): Promise<UserEntity> {
    return this.db.users.register(name);
  }

  async update(id: string, name: string): Promise<UserEntity> {
    return this.db.users.update(id, name);
  }

  async delete(id: string): Promise<void> {
    return this.db.users.delete(id);
  }
}

Mock connector:

export const mockDbConnector: DbConnector = {
  users: {
    getById: async (id: string) =>
      Promise.resolve(new UserEntity({ id, name: "Test" })),
    getByName: async (name: string) =>
      Promise.resolve(new UserEntity({ id: "1", name })),
    register: async (name: string) =>
      Promise.resolve(new UserEntity({ id: "2", name })),
    update: async (id: string, name: string) =>
      Promise.resolve(new UserEntity({ id, name })),
    delete: async (_id: string) => Promise.resolve(),
  },
  profiles: {
    getById: async (_id: string) => Promise.resolve(null),
    getByName: async (_name: string) => Promise.resolve(null),
    register: async (_name: string) => Promise.resolve(null),
    update: async (_id: string, _name: string) => Promise.resolve(null),
    delete: async (_id: string) => Promise.resolve(),
  },
};

UserEntity (Core) is not the database table. They are different things.

“Does implementing several protocols violate the S in SOLID?” It depends. Splitting each protocol into its own class maximizes SRP. Keeping one repository per entity/aggregate is also coherent: the scope is “User data in this connector”. Practical criteria for not joining A and B in the same class:

  1. Implementing B would require another external dependency.
  2. A and B work on different entities/scopes.
  3. Cyclomatic (or cognitive) complexity rises too much.
  4. The class passes ~500 lines — subjective threshold; use team judgment.

With DI and interface segregation, CreateUserUsecase only knows CreateUserProtocol and GetUserByNameProtocol. At runtime it may receive the same UsersMockRepository instance in both parameters — without coupling to the concrete class.

Services

Services adapt Infra calls into Core: REST → service → usecase → protocol → repository → database. The same design holds for a queue handler or GraphQL resolver: different input, service in the middle.

export class UserService {
  constructor(
    private createUserUsecase: CreateUser,
    private updateUserUsecase: UpdateUser,
    private deleteUserUsecase: DeleteUser,
    private getUserUsecase: GetUser,
  ) {}

  async getUser(id: string): Promise<UserEntity> {
    try {
      return await this.getUserUsecase.execute(id);
    } catch (error) {
      console.error(error);
      throw RestError.fromBaseException(error);
    }
  }

  async createUser(name: string): Promise<UserEntity> {
    try {
      return await this.createUserUsecase.execute(name);
    } catch (error) {
      console.error(error);
      throw RestError.fromBaseException(error);
    }
  }

  async updateUser(id: string, name: string): Promise<UserEntity> {
    try {
      return await this.updateUserUsecase.execute(id, name);
    } catch (error) {
      console.error(error);
      throw RestError.fromBaseException(error);
    }
  }

  async deleteUser(id: string): Promise<void> {
    try {
      return await this.deleteUserUsecase.execute(id);
    } catch (error) {
      console.error(error);
      throw RestError.fromBaseException(error);
    }
  }
}

The service depends on features (contracts), not concrete usecase classes. It maps Core exceptions to RestError, which Infra translates into HTTP responses.

Good practice: services only for Infra; one service per input “instrument” (HTTP controller ≠ queue handler), except middleware in the same stack reusing the same service.

Infra

Framework, DI, controllers, DTOs, and boilerplate that is neither business nor adapter.

NestJS controller:

// infra/controllers/UserController.ts
@Controller("users")
export class UserController {
  constructor(private service: UserService) {}

  @Get(":id")
  async getUser(@Param("id") id: string): Promise<UserEntity> {
    return this.service.getUser(id);
  }

  @Post()
  async createUser(@Body() user: UserDto): Promise<UserEntity> {
    return this.service.createUser(user.name);
  }

  @Put(":id")
  async updateUser(
    @Param("id") id: string,
    @Body() user: UserDto,
  ): Promise<UserEntity> {
    return this.service.updateUser(id, user.name);
  }

  @Delete(":id")
  async deleteUser(@Param("id") id: string): Promise<void> {
    return this.service.deleteUser(id);
  }
}

DTO:

// infra/dtos/UserDto.ts
export class UserDto implements UserEntityProps {
  id?: string;
  name: string;

  constructor(props: UserEntityProps) {
    this.id = props.id;
    this.name = props.name;
  }
}

DI module:

// infra/modules/UserModule.ts
@Module({
  imports: [],
  controllers: [UserController],
  providers: [
    UserService,
    { provide: CreateUser, useClass: CreateUserUsecase },
    { provide: UpdateUser, useClass: UpdateUserUsecase },
    { provide: DeleteUser, useClass: DeleteUserUsecase },
    { provide: GetUser, useClass: GetUserUsecase },
    { provide: CreateUserProtocol, useClass: UserPrismaRepository },
    { provide: UpdateUserProtocol, useClass: UserPrismaRepository },
    { provide: DeleteUserProtocol, useClass: UserPrismaRepository },
    { provide: GetUserByNameProtocol, useClass: UserPrismaRepository },
    { provide: GetUserByIdProtocol, useClass: UserPrismaRepository },
  ],
})
export class UserModule {}

Infra is freer: it depends on tooling and exists to sustain Core.

Execution flow

Controller (Infra) → Service (Adapter) → Usecase (Core) → Protocol (contract) → Repository (Adapter) → database (Infra). The usecase does not import the concrete repository; the service does not import the concrete usecase class. The dependency arrow points inward.

Complementary CRUD

Beyond registration:

Fetch

// core/features/GetUser.ts
export abstract class GetUser {
  abstract execute(id: string): Promise<UserEntity>;
}

// core/usecases/GetUserUsecase.ts
export class GetUserUsecase implements GetUser {
  constructor(private readonly getProtocol: GetUserByIdProtocol) {}

  async execute(id: string): Promise<UserEntity> {
    return this.getProtocol.getById(id);
  }
}

Update

// core/features/UpdateUser.ts
export abstract class UpdateUser {
  abstract execute(id: string, name: string): Promise<UserEntity>;
}

// core/usecases/UpdateUserUsecase.ts
export class UpdateUserUsecase implements UpdateUser {
  constructor(
    private readonly updateProtocol: UpdateUserProtocol,
    private readonly getByIdProtocol: GetUserByIdProtocol,
  ) {}

  async execute(id: string, name: string): Promise<UserEntity> {
    const exists = await this.getByIdProtocol.getById(id);

    if (!exists) {
      throw new UserNotExistsException(`User with id ${id} not exists`);
    }

    return this.updateProtocol.update(id, name);
  }
}

Delete

// core/features/DeleteUser.ts
export abstract class DeleteUser {
  abstract execute(id: string): Promise<void>;
}

// core/usecases/DeleteUserUsecase.ts
export class DeleteUserUsecase implements DeleteUser {
  constructor(
    private readonly deleteProtocol: DeleteUserProtocol,
    private readonly getByIdProtocol: GetUserByIdProtocol,
  ) {}

  async execute(id: string): Promise<void> {
    const exists = await this.getByIdProtocol.getById(id);

    if (!exists) {
      throw new UserNotExistsException(`User with id ${id} not exists`);
    }

    return this.deleteProtocol.delete(id);
  }
}

Remaining exception and protocols

export class UserNotExistsException extends IBaseException {
  constructor(message: string) {
    super(message);
    this.code = 404;
  }
}
// core/protocols/GetUserByIdProtocol.ts
export abstract class GetUserByIdProtocol {
  abstract getById(id: string): Promise<UserEntity>;
}

// core/protocols/UpdateUserProtocol.ts
export abstract class UpdateUserProtocol {
  abstract update(id: string, name: string): Promise<UserEntity>;
}

// core/protocols/DeleteUserProtocol.ts
export abstract class DeleteUserProtocol {
  abstract delete(id: string): Promise<void>;
}

Conclusion

Separating Core, Adapters, and Infra leaves business rules testable without Nest, Prisma, or HTTP. Swapping databases or input channels becomes an adapter swap plus DI wiring — not a usecase rewrite. The cost is more files and boundary discipline; the gain shows when the system must change without dragging the domain along.

Originally published on Medium (03/15/2023).