Backend API case study

An approval workflow API where the rules do the work.

A compact REST API where the business rules are the interesting part: who can approve, when authority changes, and how failures stay legible. Each scenario maps back to running tests instead of living only in prose.

Domain
Approval workflow
Surface
3 REST endpoints
Scenarios
3 rule paths
Code & tests
C#/.NET · TypeScript
Boundary
Local HTTP adapter
Source files Show files in the companion repository

C#/.NET implementation

TypeScript/Node.js implementation

01 — Problem

Approval APIs look simple until the rules start pulling apart.

A team needs to submit, approve, or reject change requests. CRUD endpoints get a prototype moving quickly, but the real API question is how authority, state, errors, and audit history behave when the request is not simple.

  • Who is allowed to act, given the request and the caller's role?
  • When does approval authority shift to a different role?
  • How does the API distinguish payload errors, permission errors, and rule violations?
  • What does it leave behind so the decision can be reconstructed later?
02 — Constraints

Constraints that keep the example grounded.

03 — Architecture

Three layers, with the rules in the middle.

The REST boundary translates inputs and serializes results. The domain evaluates the rules and returns a tagged result. Persistence is an adapter, because the approval rules should not need to know where the request is stored.

HTTP adapter Workflow enginepure functions ApiResult<T>
Out of band Persistence adapter Auth middleware Clock
The domain never imports a database client, a logger, or a clock. Tests inject the clock; the REST adapter supplies the operational boundary.
04 — Contract

A small API surface with explicit behavior.

The OpenAPI contract is in the public repository: approval-workflow.openapi.json .

05 — Rules & authorization

Failures need codes, not just prose.

Each rule has a named code and an HTTP status. Consumers can build deterministic UI without pattern-matching on human-readable messages, and maintainers can see the rule surface without reading controller code.

Code Status Behavior
approval_role_required 403

Only manager or finance roles can approve or reject. The check fires before state evaluation, so the caller knows this is a permission problem.

request_not_pending 409

Approve and reject only operate on pending requests. Re-approving an approved request is a 409, not a quiet no-op.

separation_of_duties_required 409

The requester cannot approve their own request. This rule lives in the engine, not in a controller.

finance_review_required 422

Requests above the manager limit must be approved by finance. The response includes a nextAction so the client does not have to guess the next step.

validation_failed 422

Payload validation runs before workflow logic and returns the full list of issues, not only the first one.

06 — Scenarios

Three scenarios, with responses produced at build time.

Pick a scenario. The request and response below come from the same module that backs the tests, evaluated at build time, so the examples stay tied to executable behavior.

Choose an approval workflow scenario

Submission to approval, with audit history attached.

The standard case: a request below the manager limit is created, validated, and approved by a different manager. The audit trail records both events, because the state change should explain itself later.

What this shows

The contract does the boring but important part: the response shape stays stable while the state transition and audit history stay visible.

Design tradeoff

The workflow rules run in pure functions. The HTTP layer only translates inputs and serializes results, which keeps the rules testable without starting a server.

  1. Step 1

    Create approval request

    POST /approval-requests

    Request

    {
      "title": "Renew analytics workspace seats",
      "amount": 1200,
      "costCenter": "CC-2040",
      "justification": "Keeps the delivery team on the supported analytics environment.",
      "requesterId": "u-requester"
    }

    Response 201

    {
      "status": 201,
      "body": {
        "id": "APR-1001",
        "title": "Renew analytics workspace seats",
        "amount": 1200,
        "requesterId": "u-requester",
        "costCenter": "CC-2040",
        "justification": "Keeps the delivery team on the supported analytics environment.",
        "requiredApprovalRole": "manager",
        "state": "pending",
        "createdAt": "2026-05-15T09:00:00.000Z",
        "updatedAt": "2026-05-15T09:00:00.000Z",
        "version": 1,
        "audit": [
          {
            "actorId": "u-requester",
            "action": "submitted",
            "at": "2026-05-15T09:00:00.000Z"
          }
        ]
      }
    }
  2. Step 2

    Approve request

    POST /approval-requests/APR-1001/approve

    Request

    {
      "actorId": "u-manager",
      "role": "manager"
    }

    Response 200

    {
      "status": 200,
      "body": {
        "id": "APR-1001",
        "title": "Renew analytics workspace seats",
        "amount": 1200,
        "requesterId": "u-requester",
        "costCenter": "CC-2040",
        "justification": "Keeps the delivery team on the supported analytics environment.",
        "requiredApprovalRole": "manager",
        "state": "approved",
        "createdAt": "2026-05-15T09:00:00.000Z",
        "updatedAt": "2026-05-15T09:05:00.000Z",
        "version": 2,
        "audit": [
          {
            "actorId": "u-requester",
            "action": "submitted",
            "at": "2026-05-15T09:00:00.000Z"
          },
          {
            "actorId": "u-manager",
            "action": "approved",
            "at": "2026-05-15T09:05:00.000Z"
          }
        ]
      }
    }
Show the test that locks this scenario in
it("creates and approves a standard request with explicit state and audit changes", () => {
    const created = createApprovalRequest(standardInput(), {
      id: "APR-1001",
      now: fixedNow,
    });

    expect(created.status).toBe(201);

    const approved = approveRequest(created.body as ApprovalRequest, {
      userId: "u-manager",
      role: "manager",
    }, {
      now: fixedNow,
    });

    expect(approved.status).toBe(200);
    expect(approved.body).toMatchObject({
      id: "APR-1001",
      state: "approved",
      version: 2,
      audit: [
        { actorId: "u-requester", action: "submitted" },
        { actorId: "u-manager", action: "approved" },
      ],
    });
  });

A technically valid request is blocked because approval authority changes.

The amount crosses the manager limit, so the workflow assigns finance as the required approver. When a manager tries anyway, the API returns a 422 with a structured nextAction instead of pretending the request is fine.

What this shows

The API models a decision boundary, not only payload validation. The failure says why the request is blocked and what should happen next.

Design tradeoff

I returned a structured 422 instead of silently re-routing. The cost is a slightly chattier client; the benefit is that the rule stays visible to both clients and maintainers.

  1. Step 1

    Create approval request

    POST /approval-requests

    Request

    {
      "title": "Approve year-end disaster recovery rehearsal",
      "amount": 8600,
      "costCenter": "CC-2040",
      "justification": "Covers the annual rehearsal with contracted vendor and overtime support.",
      "requesterId": "u-requester"
    }

    Response 201

    {
      "status": 201,
      "body": {
        "id": "APR-1002",
        "title": "Approve year-end disaster recovery rehearsal",
        "amount": 8600,
        "requesterId": "u-requester",
        "costCenter": "CC-2040",
        "justification": "Covers the annual rehearsal with contracted vendor and overtime support.",
        "requiredApprovalRole": "finance",
        "state": "pending",
        "createdAt": "2026-05-15T11:00:00.000Z",
        "updatedAt": "2026-05-15T11:00:00.000Z",
        "version": 1,
        "audit": [
          {
            "actorId": "u-requester",
            "action": "submitted",
            "at": "2026-05-15T11:00:00.000Z"
          }
        ]
      }
    }
  2. Step 2

    Manager attempts approval

    POST /approval-requests/APR-1002/approve

    Request

    {
      "actorId": "u-manager",
      "role": "manager"
    }

    Response 422

    {
      "status": 422,
      "body": {
        "error": {
          "code": "finance_review_required",
          "message": "This request exceeds the manager approval limit.",
          "details": {
            "requiredApprovalRole": "finance"
          },
          "nextAction": "Route to a finance approver."
        }
      }
    }
Show the test that locks this scenario in
it("routes high-value requests to finance instead of accepting manager approval", () => {
    const created = createApprovalRequest(
      {
        ...standardInput(),
        amount: 8600,
      },
      {
        id: "APR-1002",
        now: fixedNow,
      },
    );

    const approved = approveRequest(created.body as ApprovalRequest, {
      userId: "u-manager",
      role: "manager",
    });

    expect(approved.status).toBe(422);
    expect(approved.body).toMatchObject({
      error: {
        code: "finance_review_required",
        details: {
          requiredApprovalRole: "finance",
        },
        nextAction: "Route to a finance approver.",
      },
    });
  });

A caller without approval permissions is blocked at the API.

The same person who submitted the request tries to approve it. The role check fires before any business-rule evaluation and returns 403, so permission failure stays separate from workflow failure.

What this shows

Authorization is enforced at the API boundary, not assumed from the client. The error code and status make the failure mode unambiguous.

Design tradeoff

Role checks stay separate from business-rule checks. The caller can tell whether the failure is about permission or workflow state, which makes the client behavior simpler.

  1. Step 1

    Create approval request

    POST /approval-requests

    Request

    {
      "title": "Renew staging monitoring coverage",
      "amount": 640,
      "costCenter": "CC-2040",
      "justification": "Maintains non-production monitoring before the next release cycle.",
      "requesterId": "u-requester"
    }

    Response 201

    {
      "status": 201,
      "body": {
        "id": "APR-1003",
        "title": "Renew staging monitoring coverage",
        "amount": 640,
        "requesterId": "u-requester",
        "costCenter": "CC-2040",
        "justification": "Maintains non-production monitoring before the next release cycle.",
        "requiredApprovalRole": "manager",
        "state": "pending",
        "createdAt": "2026-05-15T13:00:00.000Z",
        "updatedAt": "2026-05-15T13:00:00.000Z",
        "version": 1,
        "audit": [
          {
            "actorId": "u-requester",
            "action": "submitted",
            "at": "2026-05-15T13:00:00.000Z"
          }
        ]
      }
    }
  2. Step 2

    Requester attempts approval

    POST /approval-requests/APR-1003/approve

    Request

    {
      "actorId": "u-requester",
      "role": "requester"
    }

    Response 403

    {
      "status": 403,
      "body": {
        "error": {
          "code": "approval_role_required",
          "message": "Only manager or finance roles can approve requests."
        }
      }
    }
Show the test that locks this scenario in
it("rejects callers that do not have an approval role", () => {
    const created = createApprovalRequest(standardInput(), {
      id: "APR-1003",
      now: fixedNow,
    });

    const approved = approveRequest(created.body as ApprovalRequest, {
      userId: "u-requester",
      role: "requester",
    });

    expect(approved.status).toBe(403);
    expect(approved.body).toMatchObject({
      error: {
        code: "approval_role_required",
      },
    });
  });
07 — Implementation

Same rule shape, two backend stacks.

The C#/.NET path is primary here because approval workflows are common in enterprise backend systems. The TypeScript path stays available to show that the rule boundary is not tied to one runtime.

ApprovalWorkflowEngine.cs

Create a request and derive the approval authority.

Validation, audit creation, and approver selection stay in the domain layer, where they can be tested without HTTP in the way. Open the source file.

public static ApiResult<ApprovalRequest> CreateApprovalRequest(
    ApprovalRequestInput input,
    CreateOptions? options = null)
{
    var validation = ValidateInput(input);

    if (validation is { } error)
    {
        return Failure<ApprovalRequest>(422, "validation_failed", error.Message, new Dictionary<string, object>
        {
            ["field"] = error.Field
        });
    }

    var at = Timestamp(options?.Now);
    var request = new ApprovalRequest(
        Id: options?.Id ?? Guid.NewGuid().ToString("n"),
        Title: input.Title,
        Amount: input.Amount,
        RequesterId: input.RequesterId,
        CostCenter: input.CostCenter,
        Justification: input.Justification,
        RequiredApprovalRole: input.Amount > 2500 ? ApprovalRole.Finance : ApprovalRole.Manager,
        State: ApprovalState.Pending,
        CreatedAt: at,
        UpdatedAt: at,
        Version: 1,
        Audit:
        [
            new AuditEvent(input.RequesterId, AuditAction.Submitted, at)
        ]);

    return new ApiResult<ApprovalRequest>(201, request);
}
ApprovalWorkflowEngine.cs

Approve only when role, state, and business rules agree.

Permission failures, state conflicts, and finance-review rules return different status codes because they mean different things to the caller.

public static ApiResult<ApprovalRequest> ApproveRequest(
    ApprovalRequest request,
    Actor actor,
    OperationOptions? options = null)
{
    if (actor.Role == ApprovalRole.Requester)
    {
        return Failure<ApprovalRequest>(
            403,
            "approval_role_required",
            "Only manager or finance roles can approve requests.");
    }

    if (request.State != ApprovalState.Pending)
    {
        return Failure<ApprovalRequest>(
            409,
            "request_not_pending",
            "Only pending requests can be approved.",
            new Dictionary<string, object> { ["currentState"] = request.State.ToString().ToLowerInvariant() });
    }

    if (actor.UserId == request.RequesterId)
    {
        return Failure<ApprovalRequest>(
            409,
            "separation_of_duties_required",
            "A requester cannot approve their own request.");
    }

    if (request.RequiredApprovalRole == ApprovalRole.Finance && actor.Role != ApprovalRole.Finance)
    {
        return Failure<ApprovalRequest>(
            422,
            "finance_review_required",
            "This request exceeds the manager approval limit.",
            new Dictionary<string, object> { ["requiredApprovalRole"] = "finance" },
            "Route to a finance approver.");
    }

    var at = Timestamp(options?.Now);
    var approved = request with
    {
        State = ApprovalState.Approved,
        UpdatedAt = at,
        Version = request.Version + 1,
        Audit = [.. request.Audit, new AuditEvent(actor.UserId, AuditAction.Approved, at)]
    };

    return new ApiResult<ApprovalRequest>(200, approved);
}
Show the Minimal API route mapping
group.MapPost("/", async (
    ApprovalRequestInput input,
    IApprovalRequestRepository repository) =>
{
    var result = ApprovalWorkflowEngine.CreateApprovalRequest(input);

    if (result.Body is ApprovalRequest request)
    {
        await repository.Save(request);
    }

    return Json(result);
});

group.MapPost("/{requestId}/approve", (
    string requestId,
    HttpRequest request,
    IApprovalRequestRepository repository) =>
    RunDecisionRoute(
        requestId,
        request,
        repository,
        (stored, actor) => ApprovalWorkflowEngine.ApproveRequest(stored, actor)));
engine.ts

createApprovalRequest

The TypeScript implementation keeps the same rule boundary for a Node.js service. Open the source file.

export function createApprovalRequest(
  input: ApprovalRequestInput,
  options: CreateOptions = {},
): ApiResult<ApprovalRequest> {
  const validation = validateInput(input);

  if (validation) {
    return failure(422, "validation_failed", validation.message, {
      field: validation.field,
    });
  }

  const at = timestamp(options);
  const request: ApprovalRequest = {
    id: options.id ?? createRequestId(),
    title: input.title,
    amount: input.amount,
    requesterId: input.requesterId,
    costCenter: input.costCenter,
    justification: input.justification,
    requiredApprovalRole: input.amount > 2500 ? "finance" : "manager",
    state: "pending",
    createdAt: at,
    updatedAt: at,
    version: 1,
    audit: [
      {
        actorId: input.requesterId,
        action: "submitted",
        at,
      },
    ],
  };

  return {
    status: 201,
    body: request,
  };
}
engine.ts

approveRequest

The order matters: role check, state check, separation of duties, finance-review rule, then the audited transition.

export function approveRequest(
  request: ApprovalRequest,
  actor: Actor,
  options: OperationOptions = {},
): ApiResult<ApprovalRequest> {
  if (actor.role === "requester") {
    return failure(403, "approval_role_required", "Only manager or finance roles can approve requests.");
  }

  if (request.state !== "pending") {
    return failure(409, "request_not_pending", "Only pending requests can be approved.", {
      currentState: request.state,
    });
  }

  if (actor.userId === request.requesterId) {
    return failure(
      409,
      "separation_of_duties_required",
      "A requester cannot approve their own request.",
    );
  }

  if (request.requiredApprovalRole === "finance" && actor.role !== "finance") {
    return failure(
      422,
      "finance_review_required",
      "This request exceeds the manager approval limit.",
      { requiredApprovalRole: request.requiredApprovalRole },
      "Route to a finance approver.",
    );
  }

  const at = timestamp(options);

  return {
    status: 200,
    body: {
      ...request,
      state: "approved",
      updatedAt: at,
      version: request.version + 1,
      audit: [
        ...request.audit,
        {
          actorId: actor.userId,
          action: "approved",
          at,
        },
      ],
    },
  };
}
Show the full TypeScript engine module
export type ApprovalState = "pending" | "approved" | "rejected";
export type ApprovalRole = "requester" | "manager" | "finance";
export type ApproverRole = Exclude<ApprovalRole, "requester">;

export type AuditAction = "submitted" | "approved" | "rejected";

export interface ApprovalRequestInput {
  title: string;
  amount: number;
  requesterId: string;
  costCenter: string;
  justification: string;
}

export interface Actor {
  userId: string;
  role: ApprovalRole;
}

export interface AuditEvent {
  actorId: string;
  action: AuditAction;
  at: string;
  note?: string;
}

export interface ApprovalRequest {
  id: string;
  title: string;
  amount: number;
  requesterId: string;
  costCenter: string;
  justification: string;
  requiredApprovalRole: ApproverRole;
  state: ApprovalState;
  createdAt: string;
  updatedAt: string;
  version: number;
  audit: AuditEvent[];
}

export interface ApiErrorBody {
  error: {
    code: string;
    message: string;
    details?: Record<string, unknown>;
    nextAction?: string;
  };
}

export interface ApiResult<T> {
  status: number;
  body: T | ApiErrorBody;
}

interface OperationOptions {
  now?: () => Date;
}

interface CreateOptions extends OperationOptions {
  id?: string;
}

// snippet:create-request-start
export function createApprovalRequest(
  input: ApprovalRequestInput,
  options: CreateOptions = {},
): ApiResult<ApprovalRequest> {
  const validation = validateInput(input);

  if (validation) {
    return failure(422, "validation_failed", validation.message, {
      field: validation.field,
    });
  }

  const at = timestamp(options);
  const request: ApprovalRequest = {
    id: options.id ?? createRequestId(),
    title: input.title,
    amount: input.amount,
    requesterId: input.requesterId,
    costCenter: input.costCenter,
    justification: input.justification,
    requiredApprovalRole: input.amount > 2500 ? "finance" : "manager",
    state: "pending",
    createdAt: at,
    updatedAt: at,
    version: 1,
    audit: [
      {
        actorId: input.requesterId,
        action: "submitted",
        at,
      },
    ],
  };

  return {
    status: 201,
    body: request,
  };
}
// snippet:create-request-end

// snippet:approve-request-start
export function approveRequest(
  request: ApprovalRequest,
  actor: Actor,
  options: OperationOptions = {},
): ApiResult<ApprovalRequest> {
  if (actor.role === "requester") {
    return failure(403, "approval_role_required", "Only manager or finance roles can approve requests.");
  }

  if (request.state !== "pending") {
    return failure(409, "request_not_pending", "Only pending requests can be approved.", {
      currentState: request.state,
    });
  }

  if (actor.userId === request.requesterId) {
    return failure(
      409,
      "separation_of_duties_required",
      "A requester cannot approve their own request.",
    );
  }

  if (request.requiredApprovalRole === "finance" && actor.role !== "finance") {
    return failure(
      422,
      "finance_review_required",
      "This request exceeds the manager approval limit.",
      { requiredApprovalRole: request.requiredApprovalRole },
      "Route to a finance approver.",
    );
  }

  const at = timestamp(options);

  return {
    status: 200,
    body: {
      ...request,
      state: "approved",
      updatedAt: at,
      version: request.version + 1,
      audit: [
        ...request.audit,
        {
          actorId: actor.userId,
          action: "approved",
          at,
        },
      ],
    },
  };
}
// snippet:approve-request-end

export function rejectRequest(
  request: ApprovalRequest,
  actor: Actor,
  reason: string,
  options: OperationOptions = {},
): ApiResult<ApprovalRequest> {
  if (actor.role === "requester") {
    return failure(403, "approval_role_required", "Only manager or finance roles can reject requests.");
  }

  if (request.state !== "pending") {
    return failure(409, "request_not_pending", "Only pending requests can be rejected.", {
      currentState: request.state,
    });
  }

  if (!reason || reason.trim().length < 12) {
    return failure(422, "validation_failed", "A rejection reason must be specific enough to act on.", {
      field: "reason",
    });
  }

  const at = timestamp(options);

  return {
    status: 200,
    body: {
      ...request,
      state: "rejected",
      updatedAt: at,
      version: request.version + 1,
      audit: [
        ...request.audit,
        {
          actorId: actor.userId,
          action: "rejected",
          at,
          note: reason.trim(),
        },
      ],
    },
  };
}

export function isApiError(body: unknown): body is ApiErrorBody {
  return Boolean(body && typeof body === "object" && "error" in body);
}

function validateInput(input: ApprovalRequestInput): { field: string; message: string } | null {
  if (!input || typeof input !== "object") {
    return { field: "body", message: "A JSON request body is required." };
  }

  if (!input.title || input.title.trim().length < 8) {
    return { field: "title", message: "A title of at least 8 characters is required." };
  }

  if (!Number.isFinite(input.amount) || input.amount <= 0) {
    return { field: "amount", message: "Amount must be greater than zero." };
  }

  if (!input.requesterId || input.requesterId.trim().length < 3) {
    return { field: "requesterId", message: "Requester identity is required." };
  }

  if (!/^CC-\d{4}$/.test(input.costCenter)) {
    return { field: "costCenter", message: "Cost center must match CC-0000 format." };
  }

  if (!input.justification || input.justification.trim().length < 20) {
    return {
      field: "justification",
      message: "Justification must explain the business need.",
    };
  }

  return null;
}

function failure(
  status: number,
  code: string,
  message: string,
  details?: Record<string, unknown>,
  nextAction?: string,
): ApiResult<never> {
  return {
    status,
    body: {
      error: {
        code,
        message,
        ...(details ? { details } : {}),
        ...(nextAction ? { nextAction } : {}),
      },
    },
  };
}

function timestamp(options: OperationOptions): string {
  return (options.now?.() ?? new Date()).toISOString();
}

function createRequestId(): string {
  return `APR-${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
}
08 — Tests

Tests keep the scenarios tied to executable behavior.

The test suite covers domain behavior directly and through the local REST adapter. The C#/.NET path covers the same domain decisions with xUnit, so the behavior is not tied to one language runtime.

Scenario Behavior Response Tests
Finance authority shift

Manager is blocked when request requires finance approval

422 finance_review_required
Caller cannot approve

Requester role cannot make approval decisions

403 approval_role_required
Missing actor headers

Decision routes require caller identity

401 actor_required
Unknown request id

Decision routes fail explicitly when the request does not exist

404 request_not_found
Reject with reason

Rejected requests keep the decision note in the audit trail

200 rejected
Show C# test excerpt and the full TypeScript test file
public void RoutesHighValueRequestsToFinanceInsteadOfAcceptingManagerApproval()
{
    var created = ApprovalWorkflowEngine.CreateApprovalRequest(StandardInput() with
    {
        Amount = 7200
    }, new CreateOptions(Now: FixedNow, Id: "apr-finance"));

    var request = Assert.IsType<ApprovalRequest>(created.Body);
    var approved = ApprovalWorkflowEngine.ApproveRequest(
        request,
        new Actor("manager-1", ApprovalRole.Manager));

    Assert.Equal(422, approved.Status);

    var error = Assert.IsType<ApiErrorBody>(approved.Body);
    Assert.Equal("finance_review_required", error.Error.Code);
    Assert.Equal("Route to a finance approver.", error.Error.NextAction);
    Assert.Equal("finance", error.Error.Details?["requiredApprovalRole"]);
}
import { describe, expect, it } from "vitest";
import {
  type ApprovalRequest,
  approveRequest,
  createApprovalRequest,
  rejectRequest,
} from "./engine";

const fixedNow = () => new Date("2026-01-15T10:30:00.000Z");

describe("approval workflow domain", () => {
  // snippet:test-happy-path-start
  it("creates and approves a standard request with explicit state and audit changes", () => {
    const created = createApprovalRequest(standardInput(), {
      id: "APR-1001",
      now: fixedNow,
    });

    expect(created.status).toBe(201);

    const approved = approveRequest(created.body as ApprovalRequest, {
      userId: "u-manager",
      role: "manager",
    }, {
      now: fixedNow,
    });

    expect(approved.status).toBe(200);
    expect(approved.body).toMatchObject({
      id: "APR-1001",
      state: "approved",
      version: 2,
      audit: [
        { actorId: "u-requester", action: "submitted" },
        { actorId: "u-manager", action: "approved" },
      ],
    });
  });
  // snippet:test-happy-path-end

  // snippet:test-finance-rule-start
  it("routes high-value requests to finance instead of accepting manager approval", () => {
    const created = createApprovalRequest(
      {
        ...standardInput(),
        amount: 8600,
      },
      {
        id: "APR-1002",
        now: fixedNow,
      },
    );

    const approved = approveRequest(created.body as ApprovalRequest, {
      userId: "u-manager",
      role: "manager",
    });

    expect(approved.status).toBe(422);
    expect(approved.body).toMatchObject({
      error: {
        code: "finance_review_required",
        details: {
          requiredApprovalRole: "finance",
        },
        nextAction: "Route to a finance approver.",
      },
    });
  });
  // snippet:test-finance-rule-end

  // snippet:test-unauthorized-start
  it("rejects callers that do not have an approval role", () => {
    const created = createApprovalRequest(standardInput(), {
      id: "APR-1003",
      now: fixedNow,
    });

    const approved = approveRequest(created.body as ApprovalRequest, {
      userId: "u-requester",
      role: "requester",
    });

    expect(approved.status).toBe(403);
    expect(approved.body).toMatchObject({
      error: {
        code: "approval_role_required",
      },
    });
  });
  // snippet:test-unauthorized-end

  it("returns validation detail for malformed create requests", () => {
    const created = createApprovalRequest({
      ...standardInput(),
      costCenter: "bad",
    });

    expect(created.status).toBe(422);
    expect(created.body).toMatchObject({
      error: {
        code: "validation_failed",
        details: {
          field: "costCenter",
        },
      },
    });
  });

  it("keeps rejection reasons in the audit trail", () => {
    const created = createApprovalRequest(standardInput(), {
      id: "APR-2002",
      now: fixedNow,
    });

    const rejected = rejectRequest(
      created.body as ApprovalRequest,
      { userId: "u-manager", role: "manager" },
      "Budget freeze for the current sprint window.",
      { now: fixedNow },
    );

    expect(rejected.status).toBe(200);
    expect(rejected.body).toMatchObject({
      state: "rejected",
      audit: [
        { actorId: "u-requester", action: "submitted" },
        {
          actorId: "u-manager",
          action: "rejected",
          note: "Budget freeze for the current sprint window.",
        },
      ],
    });
  });
});

function standardInput() {
  return {
    title: "Renew analytics workspace seats",
    amount: 1200,
    requesterId: "u-requester",
    costCenter: "CC-2040",
    justification: "Keeps the delivery team on the supported analytics environment.",
  };
}
09 — Tradeoffs

What I chose, what I left out, and what would come next.

  1. 01

    Pure functions, no I/O

    The engine takes a request, an actor, and a clock. Persistence and HTTP stay in thin adapters. The result is a domain that can be exercised heavily in tests without making the test setup the main story.

  2. 02

    Structured errors over silent recovery

    Failures return an error code, status, message, and sometimes a nextAction. The contract says what went wrong instead of re-routing requests under the hood.

  3. 03

    Audit trail as part of the request

    The audit trail is on the request, not hidden in a side log. Reading one response shows the decision history without joining sources.

  4. 04

    Role checks separated from rule checks

    A 403 means the caller cannot act here. A 422 means the caller could act, but a business rule blocked it. UIs can show different messages without parsing prose.

Intentionally absent

  • Durable persistence is intentionally absent. The companion repository uses an in-memory repository so the behavior stays easy to run locally.
  • Identity is represented by headers in the local adapter. A production service would accept verified identity claims from auth middleware.
  • Currency handling is simplified. A real system would normalize amounts and apply exchange rules at the boundary.

Production path

  • Auth and identity. Replace local headers with verified identity claims from an identity provider or gateway authorizer.
  • Idempotency. Use idempotency keys for create and decision routes so retries do not duplicate state changes.
  • Persistence and concurrency. Store requests in a durable database and guard approval transitions with optimistic version checks.
  • Audit retention. Separate operational reads from long-term audit retention while keeping correlation ids across both.
  • Observability. Emit structured logs, metrics, and traces around validation failures, decision outcomes, and latency.

Related pages.