Module interface
A module is a folder the CLI can copy. The interface is the runtime contract that folder must speak so it composes with other modules and with an AdonisJS 7 host.
The CLI will copy anything with a valid module.json. That is not enough. Auth, account, and a third-party invoices slice only work in the same app if they share api, ports, the envelope, and events. This page is that shared surface.
Read concepts for host versus registry. This page is the authoring contract.
Depend on the core
Every feature module lists api in registryDependencies. add then copies types, constants, and contracts as well.
your module → api → types, constants, contractsDo not ship a second envelope, a second exception type, or a second User DTO. Import:
| Import | What it is |
|---|---|
#modules/api/service | ApiService (namespace, emitSafe) |
#modules/api/exception | ApiException |
#modules/types | Envelope types, ModuleActionConfig |
#constants/exceptions | Status/code catalog |
#modules/contracts | Shared User / UserStore |
The host must have the aliases from host wiring. --wire writes them. Without those aliases the module does not boot, even if the files landed.
Domain, not HTTP
The domain slice is what plain add copies:
modules/<name>/
service.ts
contracts/
events.ts # omit if you emit nothing
options.ts # omit if you have no tunables
module.jsonThe service:
- Sets
namespaceto theconfig/modules.tskey (auth,account, …). - Takes ports and options in the constructor. It does not read
process.envorHttpContext. - Returns portable objects or
void. - Throws
ApiException(preferApiException.from(exceptions.X)). - Does not build
{ success, message, data }. Controllers do that and callctx.respond.
NotificationService does not extend ApiService and declares no events. It is a dispatcher, not a domain use-case. Use that shape only for infrastructure of that kind, not for a feature with a sequence of steps (register, checkout, cancel).
Ports
A port is a TypeScript interface in contracts/. The service depends on the interface. The host implements it in app/adapters/ (#adapters/<name>).
Rules:
- No Lucid, Drive, Mail, or
creem_iotypes on the port. - Map persistence models to DTOs inside the adapter.
- User-facing modules reuse
#modules/contractsUserandUserStore. Do not declare a second user shape. - Module-specific ports live next to the service (
PasswordResetStore,CreemClient,MailTransport).
If two modules both need "the current person," they share UserStore. If they need a new table, they get a new port, not a new copy of User.
Tests construct the service with in-memory fakes of the same ports. That is how you know the interface is real.
Failures
Throw ApiException. The wired exception handler turns it into the shared failure envelope. Do not return prepareError(...) from a domain method.
Use catalog keys in #constants/exceptions when the failure is already named (INVALID_TOKEN, ACCOUNT_UNVERIFIED). The thrown code is the entry's code (E_INVALID_TOKEN). Add a key in constants/ (and merge carefully) when you introduce a new code. Do not invent ad-hoc string codes in the service.
hostFacingExceptions in module.json documents codes for host middleware (guards, limiters). check does not enforce that list yet. Feature services may throw a different set.
Events
If the service extends ApiService and has side effects the host might listen to:
- Augment
EventsListinevents.ts. Import that file from the service so the augmentation loads. - Name events
Namespace:Verb(Auth:Login,Subscription:Activated). - List the same strings in
module.jsonevents.addmerges them intoconfig/modules.tsemits. - Call
this.emitSafe(name, payload)only. If the name is not inemits, the call is a no-op. Ifmodules.<namespace>is missing,emitSafelogs and returns.
The CLI does not register emitter.on listeners. The host does. List modules.<namespace> in configKeys so check can warn when that leaf is missing.
HTTP stays in stubs
Routes, controllers, Vine validators, Lucid models, and migrations are stubs. They are optional. They must not be in files.
Controllers:
- Validate with Vine.
- Call the service.
- Wrap the result with
ctx.respondand a#constants/responseCodestoken.
Adapters in stubs/adapters/ implement the ports with Lucid or an SDK. Factories such as createAuthService() belong there, imported as #adapters/<name>.
Manifest
module.json is how the CLI sees the module. Required in practice:
| Field | You put |
|---|---|
name | Same as the folder |
files | Domain globs (service.ts, contracts/**, module.json, …) |
registryDependencies | At least api |
stubs | Optional host globs under stubs/<folder>/ |
events / configKeys | If you emitSafe |
peerModels | #models/… Lucid adapters need |
env | Names the host must set (not written by add) |
dependencies | npm packages the domain or adapters import |
Field semantics: module.json. If the registry ships registry.json, add the name to the modules array so list prints it. add <name> still loads a folder that has module.json.
Checklist
A new module is ready when:
- Folder name equals
module.jsonname. registryDependenciesincludesapi.- The service uses ports +
ApiException+emitSafe(or is an explicit non-ApiServicedispatcher). - No
HttpContextor Lucid inservice.ts. - User identity goes through
UserStorewhen the slice is about a person. - Colocated tests construct fakes of the ports.
- Stubs, if any, sit under
stubs/and are listed instubs. registry.jsonlists the name.
Author in apps/adonis-api-stater/modules/, then local development (--registry, sync-registry).
Next steps
module.json— field catalog.api— envelope,ctx.respond, handler.auth— a full feature slice to copy.