Use Cases
Encapsulate business logic in classes with automatic input validation and tracing.
A use case is a class that owns the business logic for a single operation. exjs-controllers provides:
UseCase<TInput, TOutput>— the contract.@DefineUseCase()— class decorator that wrapsexecute(input)with validation and OpenTelemetry tracing.
Controllers call use cases directly — inject them with @Inject(...) and dispatch execute(input) from your handler.
Contract
import type { BaseSchema } from 'exjs-controllers/schemas/BaseSchema'
interface UseCase<TInput extends BaseSchema, TOutput> {
execute(input: TInput, ...args: unknown[]): TOutput | Promise<TOutput>
}@DefineUseCase()
Decorate the use-case class. The decorator replaces execute so that every invocation:
- Asserts the first argument is a
BaseSchemainstance — otherwise throws[UseCase] First argument for <name>.execute is required and must extend BaseSchema. - Calls
input.validate(). On aZodError, aUseCaseInputValidationErroris thrown — it hasstatusCode: 422and exposeserror.issues. - Runs the original
executeinside an OpenTelemetry span named<ClassName>.execute, with attributes includingexjs.use_case.name.
import { z } from 'zod'
import { Injectable } from 'exjs-controllers/decorators/DependencyInjection'
import { DefineUseCase } from 'exjs-controllers/decorators/DefineUseCase'
import { Field } from 'exjs-controllers/decorators/Field'
import { BaseSchema } from 'exjs-controllers/schemas/BaseSchema'
import type { UseCase } from 'exjs-controllers/core/UseCase'
class CreateUserInput extends BaseSchema {
@Field(z.string().min(1))
name!: string
@Field(z.string().email())
email!: string
}
class UserOutput extends BaseSchema {
@Field(z.string())
id!: string
@Field(z.string())
name!: string
}
@Injectable()
@DefineUseCase()
export class CreateUserUseCase implements UseCase<CreateUserInput, UserOutput> {
async execute(input: CreateUserInput): Promise<UserOutput> {
// input.validate() already ran here
return UserOutput.create({ id: crypto.randomUUID(), name: input.name })
}
}Extra arguments passed after input (e.g. execute(input, tenantId)) are forwarded transparently to the original method.
Calling a use case from a controller
Inject the use case into the controller and call execute(input) directly. No additional decorator is needed.
import { JsonController } from 'exjs-controllers/decorators/Controller'
import { Post } from 'exjs-controllers/decorators/HttpMethod'
import { Body } from 'exjs-controllers/decorators/Params'
import { Inject } from 'exjs-controllers/decorators/DependencyInjection'
@JsonController('/users')
export class UsersController {
constructor(
@Inject(CreateUserUseCase) private readonly createUser: CreateUserUseCase
) {}
@Post('/', { inputClass: CreateUserInput, outputClass: UserOutput })
create(@Body(CreateUserInput) input: CreateUserInput): Promise<UserOutput> {
return this.createUser.execute(input)
}
}You can forward extra arguments to execute — say, a tenant id parsed from the URL:
@Post('/:tenantId/users')
create(
@Body(CreateUserInput) input: CreateUserInput,
@Param('tenantId') tenantId: string
): Promise<UserOutput> {
return this.createUser.execute(input, tenantId)
}Validation error
UseCaseInputValidationError is exported from exjs-controllers/core/UseCase. Catch it in your global error handler to return a structured 422:
import { UseCaseInputValidationError } from 'exjs-controllers/core/UseCase'
errorHandler: (error, _request, response, _next) => {
if (error instanceof UseCaseInputValidationError) {
response.status(422).json({
message: error.message,
issues: error.issues
})
return
}
response.status(500).json({ message: 'Internal Server Error' })
}Why use cases?
- Single responsibility — the controller deals with HTTP, the use case deals with business logic.
- Testable — instantiate the use case directly, call
execute(input), assert on the result; no Express, no DI container required. - Tracing for free — every
executeshows up as its own OpenTelemetry span, attributed to the use case name.