-
Notifications
You must be signed in to change notification settings - Fork 15
Service Layer Architecture
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
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.
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.)
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.
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.
-
Signature β
method(PDO $conn, ActorContext $ctx, array $input): <data>. No$_SESSION,$_GET,$apiKey, or any superglobal. -
No transport β never
echo,header(),http_response_code(),exit,apiRespond()orapiError(). It returns plain data (usually the affected id) or throws a typedServiceError. Serialising and HTTP framing belong to the adapter. -
Typed errors β
ServiceError($kind, $code, $message). The adapter maps$kindto 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.
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.)
These are the traps we hit doing the first module. They will recur on every module.
"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.
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.
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.');
}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).
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.
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.
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.
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 rightpermissionsJSON. UI: write a session withsession_id($fixed); session_start(); $_SESSION['analyst_id']=β¦; session_write_close();and sendCookie: PHPSESSID=$fixed. -
Id-normalisation can corrupt unrelated text. Replacing the created id
10with a placeholder via\b10\balso 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, thengit stash pop. -
Sessions overwrite
$_SESSION. A CLI script that seeds$_SESSIONthen calls an endpoint that does its ownsession_start()won't share state β drive the endpoint over HTTP with the cookie, don't try to fake it in-process.
Write the logic once, in the service. Add two thin adapters. Never copy logic between
api/<module>/ and api/v1/resources/.
- Put the rule in
includes/services/<module>.phpas a static methodmethod(PDO $conn, ActorContext $ctx, array $input). Validate, write, and return the id (or throwServiceError). Noecho/header/superglobals. - UI endpoint: check the session, build
ActorContext::fromSession($conn), call the service, emit{success:true,β¦};catch (Exception $e)β{success:false, error:β¦}. - API handler: build
ActorContext::fromApiKey($apiKey), call the service, serialise + respond;catch (ServiceError $e)βapiError(serviceErrorHttpStatus($e->kind), $e->errorCode, β¦). - 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.
- π Service-Layer progress tracker β per-module status + line savings
- Architecture Β· REST API β how it works Β· SLA Management
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)