0.1.0GitHub
Architecture ConceptsModules & Discovery

Architecture Concepts

Modules & Discovery

On this page

Introduction

A Marmeon app is one program made of modules. A module is a feature in a folder of its own, modules/<name>/, and its index.ts says what the feature brings: its routes, its service providers, its configuration, its event listeners, its jobs, its migrations and its schedule. The installed framework packages join the app on their own, through discovery.

modules/notes/index.ts
import { Login } from '@marmeon/auth';
import { defineModule, listen } from '@marmeon/core';
import apiRoutes from './api-routes.ts';
import { NotesConfig } from './config.ts';
import { PruneTrashJob } from './jobs/PruneTrashJob.ts';
import { RecordLastVisit } from './listeners/RecordLastVisit.ts';
import { NotesServiceProvider } from './NotesServiceProvider.ts';
import routes from './routes.ts';
import { schedule } from './schedule.ts';

export { NoteRepository } from './NoteRepository.ts';

export default defineModule({
  name: 'notes',
  providers: [NotesServiceProvider],
  config: [NotesConfig],
  listeners: [listen(Login, RecordLastVisit)],
  jobs: [PruneTrashJob],
  migrations: new URL('./migrations/', import.meta.url),
  routes,
  apiRoutes,
  schedule,
});

The app lists its modules in bootstrap/app.ts. A module that is not listed is not loaded, however complete its folder is.

The application

bootstrap/app.ts exports the app's definition, made with defineApplication(). It describes the app and boots nothing: the marmeon command boots it for the server or a command, and each test boots a fresh instance. The directory structure page shows a new app's file. The options:

OptionWhat it is
rootThe app's root folder, with package.json, .env and index.html. A new app sets it from import.meta.dirname.
modulesThe feature modules, in the order they register and boot.
configApp-level config definitions. See configuration.
middlewareThe app's middleware: global for every request, web and api for the routes of each group. See middleware.
clientRoutesWhich named routes the browser may learn: include and except, by pattern. See URL generation.
providersApp-level service providers. They boot after the packages' and before the modules'.
listenersApp-level event listeners: listen(Event, Listener).
healthApp-level checks for /up/ready. See health checks.
discoverfalse registers none of the installed packages. Default: true.
envThe environment to check the configuration against, instead of .env and the real environment. For tests and scripts.
overrideA function that replaces bindings after every provider registered and before any boots. For tests.

createApplication(definition) boots an app from its definition and returns it. The marmeon command and createTestApp() call it for you. app.shutdown() stops the app again: every provider's shutdown() runs and closes its pools and connections. Code that needs the booted app outside of a request, such as a maintenance script, belongs in a console command: marmeon boots the app for it and shuts it down afterwards.

Defining a module

defineModule() takes the module's name and whatever the module contributes. The core knows a few keys, and each package adds its own:

KeyWhat it isFrom
nameThe module's name, in kebab case.core
providersIts service providers.core
configIts config definitions.core
listenersIts event listeners: listen(Event, Listener).core
healthIts health checks.core
routesIts route file, loaded into the web group. See routing.@marmeon/http
apiRoutesIts route file for the api group, under /api.@marmeon/http
commandsIts console commands.marmeon
migrationsThe folder of its migrations: new URL('./migrations/', import.meta.url).@marmeon/database
seedersIts seeders.@marmeon/database
exposesThe tables other modules may read and join. See repositories.@marmeon/database
jobsIts queued jobs. A worker runs only registered jobs.@marmeon/queue
notifications, recipientsIts notifications and who may receive them.@marmeon/notifications
scheduleIts scheduled work.@marmeon/scheduler
csrfExceptPaths that skip the CSRF check.@marmeon/session

Controllers, views and other classes need no list: a route names its controller, and the container builds it when a request needs it.

Creating a module

make:module writes a module with an empty route file:

pnpm marmeon make:module billing

It writes modules/billing/index.ts and modules/billing/routes.ts, and prints the two lines that register the module in bootstrap/app.ts. The directory structure page walks through a first module from there.

The order of modules

The modules register and boot in the order of modules: [...], and their routes load in that order too. When two routes match the same path, the one loaded first answers. The order rarely matters otherwise: every provider registers before any boots, so a module can use what a later module binds once the app has booted.

A module's public API

A module's index.ts is also its public face. What it exports is what the rest of the app may use, and other modules import it from #modules/<name>:

modules/dashboard/controllers/ShowDashboardController.ts
import { Controller } from '@marmeon/http';
import { NoteRepository } from '#modules/notes';

export class ShowDashboardController extends Controller {
  readonly #notes: NoteRepository;

  constructor(notes: NoteRepository) {
    super();
    this.#notes = notes;
  }

  async handle() {
    return this.view('dashboard/Show', { notes: await this.#notes.query().selectAll().limit(5).execute() });
  }
}

#modules/<name> is an import map entry of the app's package.json: "#modules/*": "./modules/*/index.ts". Node, Vite and TypeScript all resolve it.

Nothing stops a module from importing a file inside another module, until you ask for it. marmeon make:lint-config --boundaries writes the lint preset with one more rule: a module imports another module only through its entry. The lint then refuses import { NoteRepository } from '../../notes/NoteRepository.ts'.

Discovery

The framework's packages register themselves. A package that opts in with a "marmeon" field in its package.json has its service providers registered by every app that installs it, so an app has no list of packages to maintain. This is the field in the package.json of @marmeon/http:

{
  "name": "@marmeon/http",
  "marmeon": {
    "providers": ["HttpServiceProvider"],
    "browser": ["src/client/**", "src/page.ts", "src/upload-header.ts", "src/live-protocol.ts", "src/tab-sync-protocol.ts"]
  }
}

providers names classes the package's entry exports. They register in the order of the dependencies: a package after the packages it depends on. browser names the package's files that run only in the browser. The constructor injection leaves them alone, so no injection code reaches a client bundle.

marmeon discover

marmeon discover walks the app's dependencies and writes what it found to .marmeon/manifest.json: the providers of each package, and the injection lists of the classes the packages export. You rarely run it yourself. The app writes the file again by itself whenever package.json, the lockfile or the resolution conditions change, so pnpm add @marmeon/redis needs no extra step. It also writes it again when a listed package is gone, such as after a production install without the dev dependencies. marmeon build writes the production list.

The file is data and is never imported, so rewriting it restarts no development server. Git ignores .marmeon/.

A read-only .marmeon/

On a server whose app folder is read-only, the app cannot rewrite an outdated list. It discovers the packages in memory instead, logs one warning and starts. Run marmeon build or marmeon discover where the folder is writable, such as in the build stage of an image, and the warning goes away.

Without discovery

defineApplication({ discover: false }) registers none of the installed packages. The app then lists every provider it needs in providers, in the right order. This is for tests and special setups. A normal app keeps discovery on. The package development page explains how a package of your own opts in.

Testing

createTestApp() boots the app from the same definition, with its modules and the discovered packages:

modules/notes/notes.test.ts
import { createTestApp } from '@marmeon/testing';
import { expect, it } from 'vitest';
import application from '../../bootstrap/app.ts';

it('loads the notes module', async () => {
  await using app = await createTestApp(application);
  expect(app.application.modules.map((module) => module.name)).toContain('notes');
});

The testing page explains how a test boots the app.