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, naming conventions, scoping rules, and code conventions. Use this skill for ANY backend API work — it references sub-skills for deep implementation details.
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.
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
execute()). Coordinates services, repositories, and events. Transient scope.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;
}
.inSingletonScope())features/{serviceName}/ or features/services/{serviceName}/async getService() that lazily initializes and caches the service. Consumers inject the provider, not the service directly. See the ServiceProvider section below.Single-method orchestrators with an execute() method. They coordinate services, repositories, and events.
export interface ISyncProjectUseCase {
execute(input: SyncProjectInput): Promise<Result<Project, SyncProjectError>>;
}
features/{ActionEntity}/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;
}
}
await provider.getService() before using the serviceLingotekServiceProvider, not LingotekServiceFeatures use a two-level naming convention:
This makes features discoverable by what they DO, and once inside a feature folder, you see the technical components clearly.
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
features/
├── EntryAfterCreateHandler/ ← ❌ technical name as feature directory
├── DocumentBeforeDeleteHandler/ ← ❌ technical name as feature directory
syncToLingotek, cleanupOnDelete, notifySlackEntryAfterCreateHandler.ts, SyncProjectUseCase.tsfeatures/, never in a separate handlers/ directoryWhen: 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
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
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
| You're building a... | It needs to... | Inject |
|---|---|---|
| Event Handler | Call external API | Service |
| Event Handler | Orchestrate CMS + external | UseCase |
| Event Handler | Just log/validate | Logger (or nothing) |
| GraphQL Resolver | Simple read | Service or Repository directly |
| GraphQL Resolver | Complex mutation | UseCase |
| GraphQL Resolver | Check permissions | IdentityContext or Permissions abstraction |
| UseCase | Call external API | Service |
| UseCase | Persist/read data | Repository |
| UseCase | Publish domain events | EventPublisher |
| UseCase | Check permissions | IdentityContext or Permissions abstraction |
| Repository | Access CMS | GetModelUseCase, CreateEntryUseCase, etc. |
// 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");
features/DocumentBeforeDeleteHandler/ ← WRONG: technical name
features/cleanupLingotekDocument/ ← CORRECT: business capability
// WRONG — no builder pattern exists
builder.role({ ... }).permissions([...])
// CORRECT — factories return plain objects
async execute(): Promise<CodeRole[]> {
return [{ name: "Admin", slug: "admin", description: "...", permissions: [...] }];
}
api/handlers/MyHandler.ts ← WRONG: handlers are features
features/myFeature/MyHandler.ts ← CORRECT: handler lives inside its feature
// WRONG
throw new Error("Not found");
// CORRECT
return Result.fail(new EntityNotFoundError(id));
// 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/
├── 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
// 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:
container.register().Feature.register(container) (not container.register(Feature)).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;
}
// 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
}
});
| Method | When 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) |
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.
Every feature defines domain-specific errors extending BaseError:
// domain/errors.ts
import { BaseError } from "@webiny/feature/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:
BaseError from @webiny/feature/apioverride readonly code with a namespaced string ("Domain/ErrorType")as const on the code for type narrowingdata, define a type and pass it as generic: BaseError<TDataType>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>>;
}
Error and Return types in the namespace for consumers// domain/EntityId.ts
import { EntryId } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
export class EntityId {
static from(id?: string) {
if (id) {
return EntryId.from(id).id; // Ensure clean id without revision suffix
}
return EntryId.create().id;
}
}
// 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;
}
}
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:
export { } syntax, NOT export *feature.ts, events.ts, or implementation files| Layer | Scope | Rationale |
|---|---|---|
| UseCase | Transient (default) | Fresh per invocation |
| Service | .inSingletonScope() | Stateful or expensive to create |
| Repository | .inSingletonScope() | One cache instance |
| Gateway | .inSingletonScope() | Stateless but expensive to create |
| EventHandler | Transient (default) | Fresh per event |
| CMS Model | Register normally | Registered once at boot |
| GraphQL Schema | Register normally | Registered once at boot |
| Artifact | Pattern | Example |
|---|---|---|
| Feature dir | {businessCapability} (camelCase) | syncToLingotek, createEntity |
| UseCase | {Action}{Entity}UseCase | CreateTenantUseCase |
| Service | {Domain}Service | LingotekService |
| Repository | {Action}{Entity}Repository | CreateTenantRepository |
| Event | {Entity}{Before|After}{Action}Event | TenantBeforeDisableEvent |
| Handler | {Entity}{Before|After}{Action}EventHandler | TenantBeforeDisableEventHandler |
| Decorator | {Action}{Entity}With{Concern} | GetEntityByIdWithAuthorization |
| Mapper | EntryTo{Entity}Mapper | EntryToFolderMapper |
| Error | {Entity}{Problem}Error | EntityNotFoundError |
createAbstraction from @webiny/feature/api — never new Abstraction()createImplementation with a dependencies array matching constructor ordercreateImplementation result (as default).js extensions in all relative imports (ESM)~ alias for package-internal absolute importsResult<T, E>. Check result.isFail() before result.valuenull — use domain-specific NotFoundErrorWhen building a new API feature:
BaseError with override readonly codeInterface + Error.Interface, uses createImplementation.Interface, uses CMS use cases, wraps errorscontainer.registerDecorator(), decoratee is last constructor paramGraphQLSchemaFactory.InterfaceInterface + Event namespaceindex.ts exports abstractions only — no features, no event classes, no implementations.js extensioncreateAbstraction<T>(name: string)Creates a typed DI token. The generic T is the interface that implementations must satisfy.
| Import | import { createAbstraction } from "webiny/api" |
|---|---|
| Returns | Abstraction<T> |
createFeature(def)Creates a feature definition that the framework loads as an extension.
| Import | import { createFeature } from "webiny/api" |
|---|---|
def.name | Unique feature name (convention: "AppName/FeatureName") |
def.register(container) | Called at startup with the DI Container instance |
createAbstraction + createFeature. Never put logic directly in an EventHandler, GraphQL resolver, or CLI command.namespace MyAbstraction { export type Interface = ...; } so consumers can type dependencies as MyAbstraction.Interface."AppName/FeatureName" convention.dependencies array must match constructor parameter order exactly.process.env at runtime — deployed API services must NEVER read process.env. All configuration flows through BuildParams..inSingletonScope())..js extensions in import paths (ESM).Api.Route and Route.InterfacecreateImplementation DI pattern and injectable services