Skip to content

Service Layer Architecture

Ed Mozley edited this page Jul 5, 2026 · 7 revisions

Service Layer β€” one implementation, two interfaces

In one sentence: the UI and the REST API should call the same business logic, so a rule is written once and can never drift between them.

See also: Architecture Β· REST API β€” how it works Β· πŸ“Š Service-Layer progress tracker

The problem this solves

FreeITSM's REST API v1 was built by mirroring each module's internal (UI) endpoints. The header comment on api/v1/resources/tickets.php says it out loud:

"Mirrors the behaviour of the internal ticket endpoints so a ticket touched via the API is indistinguishable from one touched in the UI…"

"Mirrors" is the tell. It means every write operation exists twice:

Operation UI copy (session-authed) API copy (key-authed)
Create a service api/service-status/save_service.php api/v1/resources/service_status.php
Save an incident api/service-status/save_incident.php api/v1/resources/service_status.php
…and so on for every module

Two copies of the same rules is a drift surface. Change one and forget the other, and the UI and API quietly diverge. Several past bugs were exactly this (the Forms submission break, the foreign-key series). Worse, the two copies were never quite identical to begin with β€” see Knotty issue #1 below.

Why only the writes are refactored

This refactor deliberately unifies write logic (create / update / delete) and leaves the read paths (lists, detail views, serializers) untouched. That's not laziness β€” it's where the risk and the payoff actually are:

  • The drift danger lives in writes. A write isn't just a SQL statement; it carries business rules and side effects β€” validation, audit-log rows, workflow dispatch, template emails, SLA recalculation, CSAT triggers, UTC stamping. That's the logic that was copied twice and that silently drifts when one copy changes. A read is essentially "SELECT + shape the output" β€” there's no side effect to keep in sync, so there's very little that can drift.
  • Reads aren't duplicated in the dangerous way. The UI read and the API read deliberately produce different shapes and always will: the API returns a frozen public JSON contract ({data:{…}}, typed fields), while the UI read returns whatever its own front-end needs. They aren't two copies of one thing β€” they're two different outputs over the same table. Forcing them through one function would mean either rewriting the UI to consume the API's JSON (huge churn, zero user benefit) or having the service return raw rows for each side to shape itself β€” which is effectively what already happens. Low payoff, real risk on battle-tested code.
  • The next feature plugs into the write path. Once a module's writes go through one service, emitting a lifecycle event (licence.created, ticket.updated) for outbound webhooks is one line from that single write path β€” firing identically for UI, API and automation. Reads emit nothing, so there's nothing to gain there.

In short: the careful, parity-harnessed work goes where a bug corrupts data or silently breaks an integration. A read drift, worst case, shows a slightly different field somewhere visible β€” so the read paths stay exactly as they are. (This is also why the API's serializers are kept untouched during a migration: it's what makes the API output provably byte-identical β€” see Knotty issue #4.)

The shape of the fix (ports & adapters)

Session (UI)  ─┐                                              β”Œβ”€ UI JSON  {success:true,…}
               β”œβ”€β–Ί ActorContext ─► <Module>Service::method() ──
API key       β”€β”˜        (who + scope)      (the rules)         └─ API envelope + 201/404/422…
  • The service holds the business rules. It is written once.
  • The UI and the API become thin adapters. Each one only does two things: turn its own kind of request into a call, and turn the result back into its own kind of response.

The key insight: how one function serves session and API-key auth

This is the part that trips people up. "The UI needs a session, the API needs a key β€” how can they run the same code?"

The service never sees a session or an API key. Authentication is the adapter's job. Each adapter distils its caller down to the only two facts the rules actually need β€” who is acting and what companies they can see β€” and passes them in as an ActorContext:

final class ActorContext {
    public int    $actorId;       // the analyst this action is attributed to
    public ?array $companyScope;  // null = all companies, or [tenantId, …]
    public string $source;        // 'ui' | 'api'
    public string $locale;
    static function fromSession(PDO $c): self { /* reads $_SESSION['analyst_id'] + tenancy */ }
    static function fromApiKey(array $key): self { /* reads $key['analyst_id'] + company_scope */ }
}

The two auth systems already produce those same two facts β€” apiAuthenticate() returns analyst_id + company_scope; the session equivalent reads $_SESSION['analyst_id'] + the tenancy helpers. ActorContext is just the neutral shape they both collapse into. The service is auth-agnostic; the adapters are transport-specific.

The three rules a service obeys

  1. Signature β€” method(PDO $conn, ActorContext $ctx, array $input): <data>. No $_SESSION, $_GET, $apiKey, or any superglobal.
  2. No transport β€” never echo, header(), http_response_code(), exit, apiRespond() or apiError(). It returns plain data (usually the affected id) or throws a typed ServiceError. Serialising and HTTP framing belong to the adapter.
  3. Typed errors — ServiceError($kind, $code, $message). The adapter maps $kind to a status (validation→422, not_found→404, forbidden→403, conflict→409) and passes the $code + message through verbatim, so the API's error bodies stay identical.

Worked example β€” save_service

The service (includes/services/service_status.php) β€” the rules, once:

class ServiceStatusService {
    static function saveService(PDO $conn, ActorContext $ctx, array $in): int {
        $name = trim((string)($in['name'] ?? ''));
        if ($name === '') throw new ServiceError('validation', 'missing_field', "'name' is required.");
        // …insert/update status_services (UTC timestamps, empty desc -> NULL)…
        return $id;               // the affected id β€” NOT a serialised response
    }
}

The UI adapter β€” auth + its own response shape:

if (!isset($_SESSION['analyst_id'])) { echo json_encode(['success'=>false,'error'=>'Not authenticated']); exit; }
try {
    $id = ServiceStatusService::saveService($conn, ActorContext::fromSession($conn), $data);
    echo json_encode(['success' => true]);
} catch (Exception $e) { echo json_encode(['success'=>false, 'error'=>$e->getMessage()]); }

The API adapter β€” same call, API framing:

function apiStatusServicesCreate(PDO $conn, array $apiKey, $params, array $body): void {
    try {
        $id = ServiceStatusService::saveService($conn, ActorContext::fromApiKey($apiKey), $body);
        apiRespond(apiSerializeService($conn, apiLoadService($conn, $id)), 201);
    } catch (ServiceError $e) { apiError(serviceErrorHttpStatus($e->kind), $e->errorCode, $e->getMessage()); }
}

One implementation of the rule. Two thin edges. Notice the service returns the id and each adapter decides how much to send β€” the API re-loads and serialises with its existing, untouched serialiser; the UI returns {success:true}. (Why that matters: issue #4.)


The knotty issues (read this before migrating a module)

These are the traps we hit doing the first module. They will recur on every module.

Knotty issue #1 β€” the two copies already disagreed

"Unify the UI and API into one path" sounds like pure de-duplication, but the two copies were never byte-identical β€” so unifying forces a choice about which behaviour wins. For service-status the pre-existing differences were:

# Spot UI did API did
1 create timestamps DB default (server-local) UTC
2 empty description stored "" stored null
3 bad service/impact id on an incident silently skipped 422
4 set incident status by name only by name or id

Decision (project-wide): converge to the API's behaviour. The API is the contract we promised external consumers we wouldn't break, so it stays identical; the UI's internal write behaviour shifts to match. Every convergence here made the UI more correct. Always enumerate these before you touch code, and get sign-off β€” a silent behaviour change to a working UI is exactly what erodes trust.

Knotty issue #2 β€” timestamps and the "20 offices" test

Convergence #1 (server-local β†’ UTC) isn't cosmetic; it's the storage half of getting time right across time zones. The rule the whole app is moving toward:

Store UTC. Compute SLAs in UTC. Convert to local only at display (in the browser).

Why it matters: SLA maths subtracts two instants. If both are UTC, "4 hours elapsed" is always exactly 4 hours β€” daylight saving can't add or drop an hour, and it doesn't matter which office raised the ticket. The moment a local time sneaks into that subtraction, an SLA timer jumps twice a year. The service layer is the ideal enforcement point because every write goes through one place β€” "stamp UTC" is written once, not in 40 endpoints.

Deferred, and important: this fixes storage. The display half is still open β€” the module JS does new Date("2026-07-05 12:00:00"), which JavaScript parses as local, so UTC data can render an hour off. Fixing that means a parse-as-UTC helper across every timestamp-rendering module. Tracked on the progress page.

Knotty issue #3 β€” error ordering is part of the contract

The old API update did existence-check first (404), then the empty-body check (422). If you move the empty-body guard into the adapter (before the service runs), you reverse that order and a request with both problems returns the wrong error. Fix: fold the empty-body rule into the service, after the existence load, so the order is preserved and the rule still lives in one place:

$current = self::loadServiceRow($conn, $id);            // 404 first, as the API did
if (!array_diff_key($in, ['id' => true])) {
    throw new ServiceError('validation', 'missing_field', 'No fields to update.');
}

Knotty issue #4 β€” proving byte-identical

The promise "the API doesn't change" has to be proven, not hoped for. Two things make it hold:

  • Don't touch the serialisers. The service returns an id; the API adapter re-loads and serialises with the same functions as before. Identical input to an unchanged serialiser β†’ identical output, trivially.
  • A before/after parity harness (see issue #8).

Knotty issue #5 β€” ServiceError::$code collides with Exception::$code

Exception already has a $code property (an int). Declaring public string $code in a subclass is a fatal error (Type of ServiceError::$code must not be defined). Name it $errorCode instead. The constructor can still accept $code β€” just assign it to $this->errorCode.

Knotty issue #6 β€” deletes stop being idempotent (on purpose)

The old UI delete was idempotent: deleting a non-existent id returned success. The API delete 404s. Converging to the API means the UI now 404s too β€” so double-clicking delete shows an error the second time. This is a real, if minor, UX change; flag it, don't let it surprise you.

Knotty issue #7 β€” tiny response-shape fidelity

Byte-identical means byte-identical. The old API delete responds with ['id' => $params[0], …] where $params[0] is the string captured from the URL. Keep passing $params[0] (not (int)) into that response, or the JSON type flips from "5" to 5 and the harness (rightly) fails.

Knotty issue #8 β€” the parity harness and its traps

The harness seeds a temp API key and a real PHP session, drives every endpoint over HTTP, normalises volatile ids/timestamps, and diffs golden-before vs golden-after. Traps we hit:

  • Seeding both auths. API: insert a row into api_keys (act as an existing analyst) with the right permissions JSON. UI: write a session with session_id($fixed); session_start(); $_SESSION['analyst_id']=…; session_write_close(); and send Cookie: PHPSESSID=$fixed.
  • Id-normalisation can corrupt unrelated text. Replacing the created id 10 with a placeholder via \b10\b also rewrote "10-15 minutes" inside an unrelated demo incident's comment, producing a fake diff. Fix: on list endpoints, filter to just the test rows before normalising, so real data can't collide.
  • Re-capturing "before" after you've refactored. You need the golden-before on the original code. git stash push -- <the changed files> (note: not -u, or it drags in the new untracked service files), run the harness, then git stash pop.
  • Sessions overwrite $_SESSION. A CLI script that seeds $_SESSION then calls an endpoint that does its own session_start() won't share state β€” drive the endpoint over HTTP with the cookie, don't try to fake it in-process.

How to build a NEW feature that has both a UI and an API

Write the logic once, in the service. Add two thin adapters. Never copy logic between api/<module>/ and api/v1/resources/.

  1. Put the rule in includes/services/<module>.php as a static method method(PDO $conn, ActorContext $ctx, array $input). Validate, write, and return the id (or throw ServiceError). No echo/header/superglobals.
  2. UI endpoint: check the session, build ActorContext::fromSession($conn), call the service, emit {success:true,…}; catch (Exception $e) β†’ {success:false, error:…}.
  3. API handler: build ActorContext::fromApiKey($apiKey), call the service, serialise + respond; catch (ServiceError $e) β†’ apiError(serviceErrorHttpStatus($e->kind), $e->errorCode, …).
  4. If it's a lifecycle event (created/updated/resolved…), that's the one place to emit a webhook event later β€” it then fires for UI, API and automation alike.

If you're tempted to paste business logic into a second file, stop β€” that second file should be calling the service.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally