model.service.ts

<model>.service.ts is where one business action runs from start to finish: load the documents, change them, save, then tell whoever else needs to know.
Open it when an action needs more than one document, another service, a background job, an external API, or anything that must stay on the server.
Which file owns the work
The work
document
service
signal
Changing one document
state change
✓
A chain method such as story.approve() validates, changes the document and returns this.
state precondition
✓
The chain method throws when the document is in the wrong state for the change.
Running a business action
multi-document workflow
✓
Load the documents, call their chain methods, save, then notify.
cross-document rule
✓
A rule that compares several documents throws its Err here.
external API · job · server-only code
✓
Reached through injected adapters, signals and env values.
Exposing it
who may call it
✓
The endpoint's guards decide access.
the endpoint
✓
Its exec calls one service method and nothing more.
✓Belongs hereNot here
Words used on this page
database service
A service bound to one model with serve(db.<model>, …). It gets that model's methods.
plain service
A service with no model, made with serve("<name>" as const, …).
injection builder
The function you pass to serve(). Each key it returns becomes a property on this.
chain method
A document method that changes one document and returns this, e.g. story.approve().
hook
A method such as _preCreate that runs around create<Model>, update<Model> or remove<Model>.

Service Shapes

Every service is a class that extends serve(…). What you pass to it decides which of three shapes the service takes:
Database Service
serve(db.story, …)
Bound to one model. It gets storyModel, the CRUD methods, and fourteen methods per filter.
Plain Service
serve("base" as const, …)
No model. For runtime coordination, scheduled work, shared server features, or app-level orchestration.
Extended Service
serve(db.user, …, ...user.services)
A database service that also mixes in a lib's service for the same model, then adds app-specific behaviour.
A database service, complete with its imports:
apps/koyo/lib/story/story.service.ts
  • db is a value import, srv a type import. serve() needs the model at runtime; services are only named as types, so the runtime import graph stays lazy.
  • Injected keys become properties. actionLogService is read as this.actionLogService.
  • Methods stay short. Load, call a chain method, then return await ….save().
A plain service has no model. The framework's own BaseService is one:
pkgs/akanjs/service/base.service.ts

What serve() Gives You

serve() returns a class for you to extend. What that class already carries depends on the first argument:
What you get
Database
Plain
From the model
<model>Model
✓
The model adaptor, such as this.storyModel.
get<Model> … remove<Model>
✓
The six CRUD methods listed under Generated Methods.
list<Query> … updateOne<Query>
✓
Fourteen methods for each filter in the document.
_preCreate … _postRemove
✓
Hooks around create, update and remove.
On every service
logger
✓
✓
A Logger named after the class, such as StoryService.
onInit · onDestroy
✓
✓
Run once at boot and once at shutdown.
injected properties
✓
✓
Every key your injection builder returns.
...extendServices
✓
✓
Service classes passed after the builder, mixed in.
✓IncludedNot included
Arguments
db.<model>DatabaseModel
First argument for a database service.
"<name>" as conststring
First argument for a plain service.
option{ enabled?, serverMode? }optional
Goes second when present. See Service Option below.
injectBuilder({ service, use, … }) => ({ … })
Returns the properties to inject. See Injection Builder.
...extendServicesServiceCls[]optional
Mixes in their methods, injections and hooks. See Service Extension.
Service Option
The option decides which processes run the service. A batch process runs background work and takes no traffic; a federation process serves traffic. The default single process runs as all, so both are on there.
enabledboolean | (() => boolean)default true
false leaves the service out. A function runs once, the first time it is read.
serverMode"batch" | "federation"
On only where SERVER_MODE is that value or all. enabled wins when both are set.

Generated Methods

A database service gets these without writing them. Their names follow the model name and the filters declared in <model>.document.ts.
Predefined Properties
<model>Model
The model adaptor, injected automatically. Call the model's own methods and filters on it.
logger
A Logger named after the service class.
CRUD Methods
get<Model>(id)
Loads one document by id. Throws when it does not exist.
load<Model>(id?)
Loads one document by id. Returns null when it does not exist or the id is empty.
load<Model>Many(ids)
Loads several documents by id in one batch.
create<Model>(data)
Creates a document through _preCreate and _postCreate.
update<Model>(id, data)
Applies a patch through _preUpdate and _postUpdate, then returns the document.
remove<Model>(id)
Soft-removes (sets removedAt) through the remove hooks, then runs cascades.
Filter Methods
Every filter in the document generates fourteen methods. <Query> is the filter's key with a capital first letter: filter inRoot gives listInRoot.
Reads
list<Query>(...args, option?)
Lists the matching documents.
listIds<Query>(...args, option?)
Lists the ids of the matching documents.
find<Query>(...args, option?)
Finds one match, or returns null.
findId<Query>(...args, option?)
Finds the id of one match, or returns null.
pick<Query>(...args, option?)
Finds one match. Throws when there is none.
pickId<Query>(...args, option?)
Finds the id of one match. Throws when there is none.
exists<Query>(...args)
Checks for a match. Returns the id of one match, or null.
count<Query>(...args)
Counts the matching documents.
insight<Query>(...args)
Computes the model's insight over the matching documents.
query<Query>(...args)
Returns the query descriptor itself, without running it.
The trailing option. list and listIds take { sort, skip, limit, sample, select }; find, findId, pick and pickId take the same without limit. The rest take none.
Query-level writes
remove<Query>(...args)
Soft-removes every match in one atomic update.
removeOne<Query>(...args)
Soft-removes the newest match by createdAt. For at-most-one queries, not for queues.
update<Query>(...args).set(patch)
Updates every match atomically. The patch goes in .set(); the chain alone runs nothing.
updateOne<Query>(...args).set(patch)
Updates the newest match by createdAt. The result has counts, not which row changed.
Full-text search
Full-text search is not a method of its own. A filter whose query calls q.search() generates the same fourteen methods every other filter does:
apps/koyo/lib/story/
  • sort: "relevance" orders the results by match score, best first.
  • Blank text matches nothing. An empty or whitespace-only search returns no rows, not every row.

Service Extension

When an app declares a module with the same name as a lib module, such as user from libs/shared, the app's module replaces the lib's. Spread ...user.services into serve() to keep the lib's behaviour and add your own on top.
lib/__lib/lib.service.ts exports one such entry for each model the app shares with a lib:
apps/koyo/lib/user/user.service.ts
  • The lib's methods come along. Everything the lib's UserService defines is callable on this.
  • Hooks stack instead of overriding. The lib's _preCreate runs first, then yours, each receiving the previous result. Both onInit hooks run too.
  • Your injections win a name clash. A key you declare replaces the lib's key of the same name.
  • Keep app-only integrations here. Shared behaviour stays in the lib; what only this app needs, such as GitHub sign-in, goes in the app service.

Injection Builder

The function you pass to serve() is the injection builder. It receives seven helpers (database, service, use, signal, plug, env, memory) and returns an object whose keys become properties on this:
apps/koyo/lib/example/example.service.ts
  • Injected values are read-only. Only memory(…, { local: true }) stays writable.
  • The key name is part of the wiring. service() keys end in Service, signal() keys end in Signal, and use() keys match the name registered in lib/option.ts.
  • Reach for them in this order. service() for another module, plug() for an adapter, use() only for a value registered in option.ts, and env() for configuration.

Injection Types

Pick the helper by where the value comes from:
service<T>()
Another service, a lib's included. The key must end in Service; the rest names the target.
use<T>()
A value registered with option.use() in lib/option.ts. The key must match its name.
signal<T>()
A server signal, for queueing a background job or publishing an event. Key ends in Signal.
plug(Adaptor)
An adapt() adapter. If an implementation was applied to that role, you get it instead.
env(factory)
A value built at boot from the server env or process.env. Pass a factory, not env("KEY").
memory(ref, opts)
State kept in the cache adaptor, or on the instance with local: true. See below.
database()
This service's own model. A database service already has it as <model>Model.
use() and plug() in real code
The shared lib's file service reaches storage through use() and IPFS through plug():
libs/shared/lib/file/file.service.ts
env() feeding a hook
The factory receives the app's server env, typed as ModulesOptions, and runs once at boot:
apps/koyo/lib/devProject/devProject.service.ts
memory() in detail
memory(ref, opts) gives the service state that outlives one call. Without local, it lives in the app's cache adaptor. Its options:
localbooleandefault false
Keep a plain writable value on this instance instead of in the cache; on a Map, a real Map.
default
What a single value reads before its first set(), else null; a local one starts with it.
of
The value type of a Map memory, a scalar or model class. Required when ref is Map.
ttlnumber (ms)
How long each write lives, unless that set() passes its own { expireAt }.
get(stored) => value
Maps the stored value (a Map's entry value) to what code reads. Give it with set or not at all.
set(value) => stored
The inverse of get: turns what code writes back into the stored value.
What this.x turns out to be depends on how it was declared:
memory(ref, { local: true })
A plain value you read and assign directly.
memory(ref)
An object with three async methods.
memory(Map, { of: ref })
An async key–value map.
All three shapes side by side:
apps/koyo/lib/_runtime/runtime.service.ts
  • Store a model, not hand-made JSON. memory(Map, { of: cnst.OauthClient }) serializes through the constant; never encode JSON into a String memory yourself.
  • A memory belongs to the service or adaptor that declares it. Two services may both declare token; each keeps its own value.
  • A Map read of a missing key is undefined. default applies to a single value only, so guard get(key) with ??. Map entries expire one by one, on SQLite and Redis alike.
  • get and set are not allowed with local. A local memory holds the value as it is.

Business Logic Flow

A service method should read like the business action it performs. It can load documents, call their chain methods, work with other services, write logs and queue signals, all in one place.
A like is recorded through another service, then counted by the model:
apps/koyo/lib/story/story.service.ts
A backup moves through several steps, and the slow part runs later as a queued job:
apps/koyo/lib/dbBackup/dbBackup.service.ts
  • Load, save, then notify. Load every document the action needs, save, and only then call signals or other services.
  • Write return await at the end. Keep the await even where a bare return would work.
  • Mark fire-and-forget with void. When you deliberately do not wait for a call, write void in front of it so the missing await reads as intended.
  • Return null or false for "not allowed" or "not found". The signal decides whether that is an error.

Lifecycle Hooks

Hooks run around the service's create<Model>, update<Model> and remove<Model>, and once at boot and shutdown. Use one when a rule must always run; a one-off business action is a normal method.
_preCreate(data)
Runs before create<Model>. Return the data to create; you may change it.
_postCreate(doc)
Runs after the document is created. Return the document.
_preUpdate(id, data)
Runs before update<Model>. Return the patch to apply.
_postUpdate(doc)
Runs after the update. Return the document.
_preRemove(id)
Runs before remove<Model>. Check or clean up here; throw to stop the removal.
_postRemove(doc)
Runs after the soft remove. Return the document.
cascade
A cascade field removes its targets through their services, so their _postRemove runs too.
onInit()
Runs once at boot, after this service's injections are filled in.
onDestroy()
Runs once when the server shuts down.
  • Only the service's own writes run these hooks. create<Model>, update<Model> and remove<Model> go through them; a chain's .save() and the query-level writes do not.
  • Removal runs in a fixed order: _preRemove, the soft remove, _postRemove, then cascades.
Here a backup refuses to start twice for the same branch, and a new backup queues its own archive job:
apps/koyo/lib/dbBackup/dbBackup.service.ts
apps/koyo/lib/dbBackup/dbBackup.dictionary.ts

Practical Rules

  • Workflows go in the service. Anything that coordinates several models, services, signals or external APIs is a service method.
  • One-document changes go on the document. Write a chain method, then call .save() from the service when the change must persist.
  • Name injections by role. Service keys end in Service, signal keys in Signal.
  • Wrap external packages in srvkit/. Write new ones as adapt() classes and inject them with plug(); use() is for values already registered in lib/option.ts.
  • Extend a lib service instead of copying it. Spread ...<model>.services for shared behaviour and keep app-only integrations in the app service.
  • No circular dependencies. Two services cannot inject each other; move the shared operation into a smaller service or a srvkit/ helper.
  • Re-check ownership. Check that the caller owns the document even when a guard already gated the call; the two are independent gates.

Released under the MIT License

Connect your AI to these docs

MCPhttps://akanjs.com/mcp
Copyright © 2026 Akan.js All rights reserved.System managed bybassman