API Architecture Patterns

SkillDev tools

Guides your agent through building backend API features in Webiny projects following its architecture patterns.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the API Architecture Patterns skill

About this skill

The hub skill for all API/backend architecture in Webiny. Covers architecture overview, Services vs UseCases, feature naming and organization, feature structure templates, DI decision tree, anti-patterns, createFeature, createAbstraction, container registration, domain errors, entity patterns, namin

What this skill tells your AI

The instructions your AI receives, as published by webiny/webiny-js in skills/user-skills/api/api-architect/SKILL.md and read by ahel’s review.

TL;DR

API extensions use createFeature to register features into the DI container. Each feature is a vertical slice with abstractions, implementations, and a feature.ts registration file. The key abstractions are Services (multi-method, singleton) and UseCases (single-method orchestrators, transient). Repositories handle persistence via CMS. Features are named by business capability, files inside by technical responsibility.

Working Context

This skill applies to both extension developers (working in extensions/) and core developers (working in packages/). The architecture patterns are identical — only imports and registration differ.

Extensions (extensions/)Core (packages/)
Importswebiny/api, webiny/api/cms/model, etc.@webiny/feature/api, @webiny/api-headless-cms/..., etc.
Catalog pathsUse the Import: pathUse the Source: path
Entry pointexport default createFeature(...) in a file targeted by <Api.Extension src={...}>createFeature registered by the package initializer
GraphQL schemasexport default GraphQLSchemaFactory.createImplementation(...) — registered via container.register() inside the entry point's createFeatureSame pattern, but imported from @webiny/handler-graphql

Detect which context you're in by checking the file path: extensions/ → extension mode, packages/ → core mode.

Architecture Overview

Extension (root) ── registers ──> Features + GraphQL Schemas + Models
    Feature ── registers ──> UseCase | Service | EventHandler + Repository
        UseCase ── depends on ──> Service | Repository (+ EventPublisher)
            Repository ── depends on ──> CMS Use Cases (GetModel, CreateEntry, etc.)
        Service ── depends on ──> external APIs, other Services
  • Extension: Top-level entry point. Registers all features, GraphQL schemas, and CMS models.
  • Feature: A vertical slice. Registers its use cases, services, repositories, and event handlers.
  • UseCase: Single-method orchestrator (execute()). Coordinates services, repositories, and events. Transient scope.
  • Service: Multi-method abstraction for external API calls or cohesive domain logic. Singleton scope.
  • Repository: Persistence layer using CMS as storage. Singleton scope.
  • EventHandler: Thin orchestrator reacting to domain events. Delegates to services/use cases.
  • GraphQL Schema: Defines types, inputs, queries, and mutations. Resolvers delegate to use cases.
  • CMS Model: Defines the data schema stored in headless CMS.

Services vs UseCases

Services

Multi-method abstractions for external API calls or cohesive domain logic. A service groups related operations that belong together.

// abstractions.ts
export interface ILingotekService {
  translate(documentId: string, targetLocale: string): Promise<Result<void, Error>>;
  getTranslationStatus(documentId: string): Promise<Result<TranslationStatus, Error>>;
  deleteProject(projectId: string): Promise<Result<void, Error>>;
}

export const LingotekService = createAbstraction<ILingotekService>("MyExt/LingotekService");

export namespace LingotekService {
  export type Interface = ILingotekService;
}
  • Registered in singleton scope (.inSingletonScope())
  • Located in: features/{serviceName}/ or features/services/{serviceName}/
  • One service per external system or cohesive domain area
  • If async bootstrap is needed (loading settings from CMS, fetching remote config): use the ServiceProvider pattern — a provider abstraction with async getService() that lazily initializes and caches the service. Consumers inject the provider, not the service directly. See the ServiceProvider section below.

UseCases

Single-method orchestrators with an execute() method. They coordinate services, repositories, and events.

export interface ISyncProjectUseCase {
  execute(input: SyncProjectInput): Promise<Result<Project, SyncProjectError>>;
}
  • Registered in transient scope (default)
  • Located in: features/{ActionEntity}/
  • One use case per business operation

When to Create a UseCase

  • GraphQL mutations need the same logic as event handlers
  • Need to coordinate multiple services or repositories
  • Business logic must be reusable across entry points (GraphQL, events, CLI)

When NOT to Create a UseCase

  • Simple event handler that calls one service method — inject the service directly
  • Simple read queries — inject the service or repository directly into the GraphQL resolver
  • Logic that only exists in one place and is unlikely to be reused

ServiceProvider Pattern (Async Bootstrap)

When a service requires async initialization (loading CMS settings, fetching remote config, API tokens), use a ServiceProvider — a provider abstraction with async getService() that lazily creates and caches the service. Both the provider and the service are part of the same feature. The provider is the primary abstraction exported from the feature. The service itself is not registered in the DI container.

// abstractions.ts
export interface ILingotekServiceProvider {
  getService(): Promise<ILingotekService>;
}

export const LingotekServiceProvider = createAbstraction<ILingotekServiceProvider>(
  "MyExt/LingotekServiceProvider"
);

export namespace LingotekServiceProvider {
  export type Interface = ILingotekServiceProvider;
}
// LingotekServiceProvider.ts
class LingotekServiceProviderImpl implements ProviderAbstraction.Interface {
  private service: ILingotekService | undefined;

  constructor(private getSettings: GetSettingsUseCase.Interface) {}

  async getService(): Promise<ILingotekService> {
    if (!this.service) {
      const result = await this.getSettings.execute();
      const settings = result.isOk() ? result.value : defaultSettings;
      this.service = new LingotekService(settings);
    }
    return this.service;
  }
}
  • Register the provider in singleton scope (it caches the service)
  • The service itself is NOT registered in DI — it's created by the provider
  • Consumers call await provider.getService() before using the service
  • Use cases and handlers inject LingotekServiceProvider, not LingotekService

Feature Naming Philosophy

Features use a two-level naming convention:

  • Feature directory = business capability (what it does for the business)
  • Files inside = technical responsibility (what each file handles)

This makes features discoverable by what they DO, and once inside a feature folder, you see the technical components clearly.

Good

features/
├── syncToLingotek/                    ← business capability
│   ├── abstractions.ts
│   ├── SyncProjectUseCase.ts         ← technical responsibility
│   ├── EntryAfterCreateHandler.ts    ← technical responsibility (fine as filename!)
│   ├── EntryAfterUpdateHandler.ts
│   └── feature.ts
├── cleanupLingotekDocument/
│   ├── EntryBeforeDeleteHandler.ts
│   └── feature.ts

Bad

features/
├── EntryAfterCreateHandler/          ← ❌ technical name as feature directory
├── DocumentBeforeDeleteHandler/      ← ❌ technical name as feature directory

Rules

  • Feature directories describe business capability: syncToLingotek, cleanupOnDelete, notifySlack
  • Files inside describe technical responsibility: EntryAfterCreateHandler.ts, SyncProjectUseCase.ts
  • Event handlers ARE features — they live in features/, never in a separate handlers/ directory

Feature Structure Templates

Simple Event Handler Feature

When: handler calls a service or use case, no new abstractions needed.

features/cleanupOnDelete/
├── CleanupOnDeleteHandler.ts     # Implements an existing EventHandler abstraction
└── feature.ts                    # Registers the handler

Complex Feature with UseCases

When: logic is reused by GraphQL + event handlers, or coordinates multiple services.

features/syncProjectToLingotek/
├── abstractions.ts               # UseCase + error types for this feature
├── CreateProjectUseCase.ts
├── UpdateProjectUseCase.ts
├── DeleteProjectUseCase.ts
├── EntryAfterCreateHandler.ts    # Thin handler → delegates to CreateProjectUseCase
├── EntryAfterUpdateHandler.ts    # Thin handler → delegates to UpdateProjectUseCase
├── EntryAfterDeleteHandler.ts    # Thin handler → delegates to DeleteProjectUseCase
└── feature.ts                    # Registers everything

Service Feature

When: reusable multi-method service for an external API or domain area.

features/lingotekService/
├── abstractions.ts               # Service interface (multi-method)
├── LingotekService.ts            # Implementation
└── feature.ts                    # Registers in singleton scope

DI Decision Tree

What to inject based on what you're building

You're building a...It needs to...Inject
Event HandlerCall external APIService
Event HandlerOrchestrate CMS + externalUseCase
Event HandlerJust log/validateLogger (or nothing)
GraphQL ResolverSimple readService or Repository directly
GraphQL ResolverComplex mutationUseCase
GraphQL ResolverCheck permissionsIdentityContext or Permissions abstraction
UseCaseCall external APIService
UseCasePersist/read dataRepository
UseCasePublish domain eventsEventPublisher
UseCaseCheck permissionsIdentityContext or Permissions abstraction
RepositoryAccess CMSGetModelUseCase, CreateEntryUseCase, etc.

Anti-Patterns

❌ Creating one abstraction per operation instead of a multi-method Service

// WRONG — separate abstractions for related operations
export const DeleteDocumentService = createAbstraction(...)
export const CreateDocumentService = createAbstraction(...)
export const UpdateDocumentService = createAbstraction(...)

// CORRECT — one multi-method Service
export interface IDocumentService {
  create(input: CreateInput): Promise<Result<Doc, Error>>;
  update(id: string, input: UpdateInput): Promise<Result<Doc, Error>>;
  delete(id: string): Promise<Result<void, Error>>;
}
export const DocumentService = createAbstraction<IDocumentService>("MyExt/DocumentService");

❌ Naming features by technical implementation

features/DocumentBeforeDeleteHandler/    ← WRONG: technical name
features/cleanupLingotekDocument/        ← CORRECT: business capability

❌ Assuming builders exist for factories

// WRONG — no builder pattern exists
builder.role({ ... }).permissions([...])

// CORRECT — factories return plain objects
async execute(): Promise<CodeRole[]> {
  return [{ name: "Admin", slug: "admin", description: "...", permissions: [...] }];
}

❌ Separate handlers/ directory

api/handlers/MyHandler.ts               ← WRONG: handlers are features
features/myFeature/MyHandler.ts          ← CORRECT: handler lives inside its feature

❌ Using generic Error instead of domain-specific errors

// WRONG
throw new Error("Not found");

// CORRECT
return Result.fail(new EntityNotFoundError(id));

❌ Not filtering event handlers by model/entity type

// WRONG — fires for ALL models
async handle(event) {
  await this.service.doWork(event.payload.entry);
}

// CORRECT — filter by your model
async handle(event) {
  if (event.payload.model.modelId !== MY_MODEL_ID) return;
  await this.service.doWork(event.payload.entry);
}

API Directory Structure

api/
├── Extension.ts              # API entry point (createFeature, registers everything)
├── domain/
│   ├── errors.ts             # Domain-specific errors (extend BaseError)
│   ├── EntityId.ts           # Value object for entity IDs
│   ├── EntityModel.ts        # CMS model definition (ModelFactory)
│   └── EntityModelExtension.ts  # Abstraction for extending the model
├── features/
│   ├── createEntity/         # Feature: business capability
│   │   ├── abstractions.ts   # UseCase + Repository abstractions + error types
│   │   ├── feature.ts        # DI registration
│   │   ├── CreateEntityUseCase.ts
│   │   └── CreateEntityRepository.ts
│   ├── lingotekService/      # Service feature
│   │   ├── abstractions.ts
│   │   ├── LingotekService.ts
│   │   └── feature.ts
│   └── syncToLingotek/       # Event handler feature
│       ├── EntryAfterCreateHandler.ts
│       └── feature.ts
└── graphql/
    ├── CreateEntitySchema.ts
    └── GetEntitySchema.ts

API Extension Entry Point

// src/api/Extension.ts
import { createFeature } from "webiny/api";
import EntityModel from "./domain/EntityModel.js";
import CreateEntitySchema from "./graphql/CreateEntitySchema.js";
import { CreateEntityFeature } from "./features/createEntity/feature.js";
import { LingotekServiceFeature } from "./features/lingotekService/feature.js";
import { SyncToLingotekFeature } from "./features/syncToLingotek/feature.js";

export const Extension = createFeature({
  name: "MyExtension",
  register(container) {
    // CMS model (register first)
    container.register(EntityModel);

    // GraphQL schemas
    container.register(CreateEntitySchema);

    // Features (use Feature.register, NOT container.register)
    CreateEntityFeature.register(container);
    LingotekServiceFeature.register(container);
    SyncToLingotekFeature.register(container);
  }
});

Rules:

  • Register the CMS model first.
  • Register GraphQL schemas with container.register().
  • Register features with Feature.register(container) (not container.register(Feature)).

Abstractions

Every piece of business logic starts with a typed abstraction token:

// src/api/features/createEntity/abstractions.ts
import { createAbstraction, Result } from "webiny/api";
import type { MyEntity } from "~/shared/MyEntity.js";

export interface ICreateEntityInput {
  name: string;
}

export interface ICreateEntityUseCase {
  execute(input: ICreateEntityInput): Promise<Result<MyEntity, Error>>;
}

export const CreateEntityUseCase = createAbstraction<ICreateEntityUseCase>(
  "MyExtension/CreateEntityUseCase"
);

// Namespace re-exports all related types for convenient access
export namespace CreateEntityUseCase {
  export type Interface = ICreateEntityUseCase;
  export type Input = ICreateEntityInput;
}

Feature Registration

// src/api/features/createEntity/feature.ts
import { createFeature } from "webiny/api";
import CreateEntityUseCase from "./CreateEntityUseCase.js";
import CreateEntityRepository from "./CreateEntityRepository.js";

export const CreateEntityFeature = createFeature({
  name: "CreateEntity",
  register(container) {
    container.register(CreateEntityUseCase); // transient (default)
    container.register(CreateEntityRepository).inSingletonScope(); // singleton
  }
});

Container Registration Methods

MethodWhen to Use
container.register(Implementation)Register a class (created via Abstraction.createImplementation)
container.registerInstance(abstraction, instance)Register a plain object that satisfies the interface
container.registerFactory(abstraction, () => instance)Register a lazy factory
container.registerDecorator(Decorator)Register a decorator (wraps existing implementation)

Reading API BuildParams

A deployed API must NEVER use process.env to read configuration. All configuration flows through BuildParams via DI:

import { BuildParams } from "webiny/api";

class MyServiceImpl implements MyService.Interface {
  constructor(private buildParams: BuildParams.Interface) {}

  doSomething() {
    // buildParams.get() returns T | null — always handle null
    const endpoint = this.buildParams.get<string>("MY_API_ENDPOINT");
    if (!endpoint) {
      throw new Error("MY_API_ENDPOINT build param is not configured.");
    }
  }
}

export default MyService.createImplementation({
  implementation: MyServiceImpl,
  dependencies: [BuildParams]
});

Note: BuildParam declarations (<Api.BuildParam>) live in the top-level extension component — see the webiny-full-stack-architect skill.


Domain Errors

Every feature defines domain-specific errors extending BaseError:

// domain/errors.ts
import { BaseError } from "webiny/api";

export class EntityNotFoundError extends BaseError {
  override readonly code = "Entity/NotFound" as const;

  constructor(id: string) {
    super({ message: `Entity with id "${id}" was not found!` });
  }
}

export class EntityPersistenceError extends BaseError<{ error: Error }> {
  override readonly code = "Entity/Persist" as const;

  constructor(error: Error) {
    super({ message: error.message, data: { error } });
  }
}

Rules:

  • Extend BaseError from webiny/api
  • Use override readonly code with a namespaced string ("Domain/ErrorType")
  • Use as const on the code for type narrowing
  • If passing data, define a type and pass it as generic: BaseError<TDataType>

Typed Error Unions in Abstractions

Define error interfaces and union types so consumers know exactly which errors can occur:

// features/createEntity/abstractions.ts
export interface ICreateEntityErrors {
  persistence: EntityPersistenceError;
  notFound: EntityModelNotFoundError;
  notAuthorized: NotAuthorizedError;
}

type CreateEntityError = ICreateEntityErrors[keyof ICreateEntityErrors];

export interface ICreateEntityUseCase {
  execute(input: CreateEntityInput): Promise<Result<Entity, CreateEntityError>>;
}

export namespace CreateEntityUseCase {
  export type Interface = ICreateEntityUseCase;
  export type Input = CreateEntityInput;
  export type Error = CreateEntityError;
  export type Return = Promise<Result<Entity, CreateEntityError>>;
}
  • Use case errors are a superset of repository errors (use case adds authorization, validation, etc.)
  • Export Error and Return types in the namespace for consumers

Entity / Value Object Patterns

Entity ID Value Object

// domain/EntityId.ts
import { EntryId } from "webiny/api/cms/entry";

export class EntityId {
  static from(id?: string) {
    if (id) {
      return EntryId.from(id).id; // Ensure clean id without revision suffix
    }
    return EntryId.create().id;
  }
}

Domain Entity Class

// shared/Entity.ts
export interface EntityDto {
  id: string;
  values: EntityValues;
}

export class Entity {
  private constructor(private dto: EntityDto) {}

  static from(dto: EntityDto) {
    return new Entity(dto);
  }

  get id() {
    return this.dto.id;
  }
  get values() {
    return this.dto.values;
  }
}

Public Exports (index.ts)

Each feature folder exports only abstractions — never features, events, or implementations:

// features/disableEntity/index.ts
export {
  DisableEntityUseCase,
  EntityBeforeDisableEventHandler,
  EntityAfterDisableEventHandler
} from "./abstractions.js";

Rules:

  • Use export { } syntax, NOT export *
  • Do NOT export feature.ts, events.ts, or implementation files

Scoping Rules

LayerScopeRationale
UseCaseTransient (default)Fresh per invocation
Service.inSingletonScope()Stateful or expensive to create
Repository.inSingletonScope()One cache instance
Gateway.inSingletonScope()Stateless but expensive to create
EventHandlerTransient (default)Fresh per event
CMS ModelRegister normallyRegistered once at boot
GraphQL SchemaRegister normallyRegistered once at boot

Naming Conventions

ArtifactPatternExample
Feature dir{businessCapability} (camelCase)syncToLingotek, createEntity
UseCase{Action}{Entity}UseCaseCreateTenantUseCase
Service{Domain}ServiceLingotekService
Repository{Action}{Entity}RepositoryCreateTenantRepository
Event{Entity}{Before|After}{Action}EventTenantBeforeDisableEvent
Handler{Entity}{Before|After}{Action}EventHandlerTenantBeforeDisableEventHandler
Decorator{Action}{Entity}With{Concern}GetEntityByIdWithAuthorization
MapperEntryTo{Entity}MapperEntryToFolderMapper
Error{Entity}{Problem}ErrorEntityNotFoundError

Code Conventions

  • Use createAbstraction from webiny/api — never new Abstraction()
  • All implementations use createImplementation with a dependencies array matching constructor order
  • Implementation classes are not exported — only the createImplementation result (as default)
  • One class per file. One named import per line.
  • Use .js extensions in all relative imports (ESM)
  • Use ~ alias for package-internal absolute imports
  • All operations return Result<T, E>. Check result.isFail() before result.value
  • Never return null — use domain-specific NotFoundError
  • Wrap infrastructure errors in domain errors

Checklist

When building a new API feature:

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
8k
Forks
679
Last commit
Sep 2026
Advanced
Item type
skill
Key
webiny-api-architect
Source
github.com/webiny/webiny-js