webiny-use-case-pattern
$
npx mdskill add webiny/webiny-js/webiny-use-case-patternImplement business logic using Webiny UseCase patterns.
- Build single-method orchestrators for complex operations.
- Depends on Webiny API DI and Result handling.
- Executes logic via injected dependencies.
- Returns typed results with error codes.
SKILL.md
.github/skills/webiny-use-case-patternView on GitHub ↗
---
name: webiny-use-case-pattern
context: webiny-api
description: >
UseCase implementation pattern — DI, Result handling, error types, decorators, CMS repositories,
entry mappers, and schema-based permissions. Use this skill to implement, inject, override, or
decorate any Webiny UseCase, or to build repositories that persist data via CMS.
---
# UseCase Pattern
## What It Is
A **UseCase** is a single-method orchestrator that encapsulates one business operation (e.g., `CreateTenantUseCase`, `PublishEntryUseCase`). Each UseCase is a DI abstraction with an `execute` method that returns `Result<T, E>`.
## Interface Shape
```ts
interface SomeUseCase.Interface {
execute(input: Input): Promise<Result<ReturnType, ErrorType>>;
}
```
- **Input** — a typed object specific to the use case
- **Result** — always returns `Result<T, E>` from `@webiny/feature/api`
- **Error** — extends `BaseError` with a unique `code`
## How to Use a UseCase
UseCases are injected as dependencies into EventHandlers, other UseCases, or GraphQL resolvers via DI.
```ts
import { SomeUseCase } from "webiny/api/<category>";
import { SomeEventHandler } from "webiny/api/<category>";
class MyHandler implements SomeEventHandler.Interface {
constructor(private someUseCase: SomeUseCase.Interface) {}
async handle(event: SomeEventHandler.Event) {
const result = await this.someUseCase.execute({ /* input */ });
if (result.isFail()) {
console.error(result.error.message);
return;
}
const value = result.value;
// ... use value
}
}
export default SomeEventHandler.createImplementation({
implementation: MyHandler,
dependencies: [SomeUseCase]
});
```
## How to Override a UseCase
To replace the default implementation, register your own:
```ts
import { SomeUseCase } from "webiny/api/<category>";
class CustomImplementation implements SomeUseCase.Interface {
async execute(input) {
// Custom logic
return Result.ok(/* ... */);
}
}
export default SomeUseCase.createImplementation({
implementation: CustomImplementation,
dependencies: []
});
```
## Registration
**YOU MUST include the full file path with the `.ts` extension in the `src` prop.** For example, use `src={"@/extensions/my-extension.ts"}`, NOT `src={"@/extensions/my-extension"}`. Omitting the file extension will cause a build failure.
**YOU MUST use `export default` for the `createImplementation()` call** when the file is targeted directly by an Extension `src` prop. Using a named export (`export const Foo = SomeFactory.createImplementation(...)`) will cause a build failure. Named exports are only valid inside files registered via `createFeature`.
```tsx
// In your app's configuration
<Api.Extension src={"@/extensions/my-extension.ts"} />
```
Deploy with: `yarn webiny deploy api --env=dev`
---
## Error Handling Pattern
### Domain-Specific Errors
Every feature defines errors extending `BaseError`. Never use generic `Error` for validation or business rule failures.
```ts
// 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 } });
}
}
export class EntityValidationError extends BaseError<{ message: string }> {
override readonly code = "Entity/Validation" as const;
constructor(message: string) {
super({ message, data: { message } });
}
}
```
### Typed Error Unions in Abstractions
Define an `IErrors` interface mapping error names to types, then create a union via `[keyof IErrors]`:
```ts
// features/createEntity/abstractions.ts
import { createAbstraction, Result } from "@webiny/feature/api";
import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/errors.js";
import { EntityPersistenceError, EntityModelNotFoundError, EntityCreationError } from "~/api/domain/errors.js";
// REPOSITORY errors
export interface ICreateEntityRepositoryErrors {
persistence: EntityPersistenceError;
modelNotFound: EntityModelNotFoundError;
creation: EntityCreationError;
}
type RepositoryError = ICreateEntityRepositoryErrors[keyof ICreateEntityRepositoryErrors];
export interface ICreateEntityRepository {
execute(entity: Entity): Promise<Result<Entity, RepositoryError>>;
}
export const CreateEntityRepository = createAbstraction<ICreateEntityRepository>(
"MyExt/CreateEntityRepository"
);
export namespace CreateEntityRepository {
export type Interface = ICreateEntityRepository;
export type Error = RepositoryError;
export type Return = Promise<Result<Entity, RepositoryError>>;
}
// USE CASE errors — superset of repository errors
export interface ICreateEntityUseCaseErrors {
persistence: EntityPersistenceError;
modelNotFound: EntityModelNotFoundError;
creation: EntityCreationError;
notAuthorized: NotAuthorizedError;
}
type UseCaseError = ICreateEntityUseCaseErrors[keyof ICreateEntityUseCaseErrors];
export interface ICreateEntityUseCase {
execute(input: CreateEntityInput): Promise<Result<Entity, UseCaseError>>;
}
export const CreateEntityUseCase = createAbstraction<ICreateEntityUseCase>(
"MyExt/CreateEntityUseCase"
);
export namespace CreateEntityUseCase {
export type Interface = ICreateEntityUseCase;
export type Input = CreateEntityInput;
export type Error = UseCaseError;
export type Return = Promise<Result<Entity, UseCaseError>>;
}
```
### Result Pattern
```ts
// Success
return Result.ok(value);
// Failure
return Result.fail(new EntityNotFoundError(id));
// Check result
if (result.isFail()) {
return Result.fail(result.error);
}
// Access value
const value = result.value;
```
Never use `result.isError()`, `result.getError()`, or `result.getValue()` — these do not exist.
---
## UseCase Implementation
```ts
// features/createEntity/CreateEntityUseCase.ts
import { CreateEntityUseCase as UseCaseAbstraction, CreateEntityRepository } from "./abstractions.js";
import { Result } from "@webiny/feature/api";
import { IdentityContext } from "@webiny/api-core/exports/api/security.js";
import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/errors.js";
import { Entity } from "~/shared/Entity.js";
import { EntityId } from "~/api/domain/EntityId.js";
class CreateEntityUseCase implements UseCaseAbstraction.Interface {
constructor(
private identityContext: IdentityContext.Interface,
private repository: CreateEntityRepository.Interface
) {}
async execute(input: UseCaseAbstraction.Input): UseCaseAbstraction.Return {
if (!this.identityContext.getPermission("mypackage.entity")) {
return Result.fail(new NotAuthorizedError({ message: "Not authorized to create entities!" }));
}
const entity = Entity.from({
id: EntityId.from(input.id),
values: { name: input.name, status: "disabled" }
});
const result = await this.repository.execute(entity);
if (result.isFail()) {
return Result.fail(result.error);
}
return Result.ok(result.value);
}
}
export default UseCaseAbstraction.createImplementation({
implementation: CreateEntityUseCase,
dependencies: [IdentityContext, CreateEntityRepository]
});
```
**Rules:**
- Class implements `UseCaseAbstraction.Interface`
- Constructor params typed with `.Interface` from their abstractions
- Return type uses `UseCaseAbstraction.Return`
- `dependencies` array matches constructor parameter order exactly
- Export as `default`
---
## CMS Repository Pattern
Repositories use CMS use cases to persist data. Always resolve the CMS model first.
```ts
// features/createEntity/CreateEntityRepository.ts
import { Entity } from "~/shared/Entity.js";
import { EntityCreationError, EntityModelNotFoundError } from "~/api/domain/errors.js";
import { CreateEntityRepository as RepositoryAbstraction } from "./abstractions.js";
import { Result } from "@webiny/feature/api";
import { CreateEntryUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
import { GetModelUseCase } from "@webiny/api-headless-cms/exports/api/cms/model";
import { ENTITY_MODEL_ID } from "~/shared/constants.js";
class CreateEntityRepository implements RepositoryAbstraction.Interface {
constructor(
private getModelUseCase: GetModelUseCase.Interface,
private createEntryUseCase: CreateEntryUseCase.Interface
) {}
async execute(entity: Entity): RepositoryAbstraction.Return {
const modelResult = await this.getModelUseCase.execute(ENTITY_MODEL_ID);
if (modelResult.isFail()) {
return Result.fail(new EntityModelNotFoundError());
}
const createResult = await this.createEntryUseCase.execute(modelResult.value, {
id: entity.id,
values: {
name: entity.values.name,
status: entity.values.status
}
});
if (createResult.isFail()) {
return Result.fail(new EntityCreationError(createResult.error));
}
return Result.ok(entity);
}
}
export default RepositoryAbstraction.createImplementation({
implementation: CreateEntityRepository,
dependencies: [GetModelUseCase, CreateEntryUseCase]
});
```
### Common CMS Use Cases for Repositories
```ts
import { CreateEntryUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
import { GetEntryByIdUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
import { GetEntryUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
import { UpdateEntryUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
import { ListLatestEntriesUseCase } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
import { EntryId } from "@webiny/api-headless-cms/exports/api/cms/entry.js";
import { GetModelUseCase } from "@webiny/api-headless-cms/exports/api/cms/model";
import { ListModelsUseCase } from "@webiny/api-headless-cms/exports/api/cms/model.js";
```
**Rules:**
- Always resolve the CMS model first via `GetModelUseCase`
- Wrap CMS errors in domain-specific errors
- Register repositories in **singleton scope**
- Export as `default`
---
## Entry-to-Entity Mapper
When repositories return CMS entries, use a mapper to convert to domain types:
```ts
// features/shared/EntryToEntityMapper.ts
import { Entity as EntityClass } from "~/shared/Entity.js";
import type { Entity, EntityDto, EntityValues } from "~/shared/Entity.js";
export class EntryToEntityMapper {
static toEntity(entry: { entryId: string; values: EntityValues }): Entity {
return EntityClass.from({
id: entry.entryId,
values: entry.values
});
}
}
```
- Static methods only — no instance state
- Used by repositories, not by use cases directly
- Handle null/undefined values with defaults where appropriate
---
## UseCase Decorators
Decorators add cross-cutting concerns (authorization, logging, validation) without modifying the core use case.
```ts
// features/getEntityById/decorators/GetEntityByIdWithAuthorization.ts
import { GetEntityByIdUseCase } from "../abstractions.js";
import { Result } from "@webiny/feature/api";
import { IdentityContext } from "@webiny/api-core/exports/api/security.js";
import { NotAuthorizedError } from "@webiny/api-core/features/security/shared/errors.js";
class GetEntityByIdWithAuthorizationImpl implements GetEntityByIdUseCase.Interface {
constructor(
private identityContext: IdentityContext.Interface,
private decoratee: GetEntityByIdUseCase.Interface // decoratee is LAST
) {}
async execute(id: string): GetEntityByIdUseCase.Return {
if (!this.identityContext.getPermission("mypackage.entity")) {
return Result.fail(new NotAuthorizedError());
}
return this.decoratee.execute(id);
}
}
export const GetEntityByIdWithAuthorization = GetEntityByIdUseCase.createDecorator({
decorator: GetEntityByIdWithAuthorizationImpl,
dependencies: [IdentityContext] // does NOT include decoratee
});
```
### Registering a Decorator
```ts
// features/getEntityById/feature.ts
import { createFeature } from "@webiny/feature/api";
import GetEntityByIdUseCase from "./GetEntityByIdUseCase.js";
import GetEntityByIdRepository from "./GetEntityByIdRepository.js";
import { GetEntityByIdWithAuthorization } from "./decorators/GetEntityByIdWithAuthorization.js";
export const GetEntityByIdFeature = createFeature({
name: "GetEntityById",
register(container) {
container.register(GetEntityByIdUseCase);
container.register(GetEntityByIdRepository).inSingletonScope();
container.registerDecorator(GetEntityByIdWithAuthorization);
}
});
```
**Rules:**
- Implements the same interface as the use case it decorates
- Constructor: extra dependencies first, `decoratee` **last**
- Use `UseCaseAbstraction.createDecorator(...)` — the `dependencies` array does NOT include the decoratee
- Register with `container.registerDecorator()`, not `container.register()`
- Can modify input before delegating, output after, or short-circuit with an error
---
## Schema-Based Permissions
For implementing authorization in use cases, see the **webiny-api-permissions** skill. It covers:
- Permission schema definition with `createPermissions`
- All permission methods (`canRead`, `canEdit`, `canDelete`, `canPublish`, `onlyOwnRecords`, etc.)
- Use case patterns for every CRUD operation (get, list, update, delete, publish)
- Own-record scoping and item-level ownership checks
- Testing patterns and permission object shapes
---
## Resolving Types (MANDATORY)
**Before writing any code that calls a UseCase or accesses its return types, you MUST read the source file listed in the catalog's `Source` field to verify the exact method signatures, input parameters, return types, and error types. Do not assume or guess property names from memory.**
1. Read the `abstractions.ts` file from the catalog `Source` path
2. If the interface references domain types, follow the import and read that type declaration
3. Only use properties and method signatures confirmed in the source
## Key Rules
- Always check `result.isFail()` before accessing `.value` or `.error`
- DI constructor parameter order must match the `dependencies` array order exactly
- Use `.js` extensions in import paths (ES modules)
## Related Skills
- **webiny-api-architect** — Architecture overview, Services vs UseCases, feature naming, anti-patterns
- **webiny-api-permissions** — Schema-based permissions, CRUD authorization patterns, testing
- **webiny-event-handler-pattern** — EventHandler lifecycle, domain event publishing
- **webiny-custom-graphql-api** — GraphQL schema creation with UseCase DI
- **webiny-dependency-injection** — Injectable services catalog
More from webiny/webiny-js
- testerUse when running tests. Shows how to run tests for a single package, including OpenSearch (ddb-os) tests when applicable.
- webiny-admin-aco-catalog>
- webiny-admin-architect>
- webiny-admin-build-params-catalog>
- webiny-admin-catalog>
- webiny-admin-cms-catalog>
- webiny-admin-configs-catalog>
- webiny-admin-env-config-catalog>
- webiny-admin-form-catalog>
- webiny-admin-graphql-client-catalog>