Skip to content

Project Structure

After completing Your First Worker you have a small project with a controller, a service, a root module, and an entry point. This page explains how to organise those files — and the ones you will add next — as your application grows.

A freshly scaffolded Stratal project looks like this:

my-worker/
├── src/
│ ├── app.module.ts # Root module
│ ├── hello.controller.ts # Controller
│ ├── hello.service.ts # Service
│ └── index.ts # Worker entry point
├── package.json
├── tsconfig.json
└── wrangler.jsonc

This flat structure works fine for a handful of files. Once you have more than one domain concept you should move to feature modules.

Stratal uses a suffix-based naming convention so you can tell what a file does at a glance:

SuffixPurposeExample
.controller.tsHandles HTTP requests for a set of routesnotes.controller.ts
.service.tsContains business logic, injected via DInotes.service.ts
.module.tsDeclares controllers, providers, and importsnotes.module.ts
.consumer.tsProcesses messages from a Cloudflare Queueemail.consumer.ts
.job.tsDefines a scheduled cron jobcleanup.job.ts
.guard.tsImplements CanActivate to protect routesauth.guard.ts
.middleware.tsRuns before a controller method executesrequest-logger.middleware.ts
.tokens.tsExports DI tokens (Symbols) for a featurenotes.tokens.ts
.schemas.tsZod schemas for request/response validationnotes.schemas.ts
.error.tsCustom error classes extending ApplicationErrornote-not-found.error.ts

You are not required to use every suffix — only add files your feature actually needs.

As the project grows, group related files into a directory named after the feature. Here is a notes CRUD feature:

my-worker/
├── src/
│ ├── notes/
│ │ ├── notes.controller.ts
│ │ ├── notes.service.ts
│ │ ├── notes.schemas.ts
│ │ └── notes.module.ts
│ ├── app.module.ts
│ └── index.ts
├── package.json
├── tsconfig.json
└── wrangler.jsonc

The feature module registers its own controller and service:

import { Module } from 'stratal/module'
import { NotesController } from './notes.controller'
import { NotesService } from './notes.service'
@Module({
controllers: [NotesController],
providers: [NotesService],
})
export class NotesModule {}

Then the root module imports it:

import { Module } from 'stratal/module'
import { NotesModule } from './notes/notes.module'
@Module({
imports: [NotesModule],
})
export class AppModule {}

The root module no longer lists individual controllers or providers — each feature module owns its own.

A larger project with multiple features, queues, scheduled tasks, and cross-cutting concerns might look like this:

my-worker/
├── src/
│ ├── notes/
│ │ ├── notes.controller.ts
│ │ ├── notes.service.ts
│ │ ├── notes.schemas.ts
│ │ ├── notes.tokens.ts
│ │ └── notes.module.ts
│ ├── notifications/
│ │ ├── notifications.controller.ts
│ │ ├── notifications.service.ts
│ │ ├── notifications.consumer.ts
│ │ ├── notifications.schemas.ts
│ │ └── notifications.module.ts
│ ├── jobs/
│ │ ├── cleanup.job.ts
│ │ └── jobs.module.ts
│ ├── guards/
│ │ ├── auth.guard.ts
│ │ └── guards.module.ts
│ ├── middleware/
│ │ ├── request-logger.middleware.ts
│ │ └── middleware.module.ts
│ ├── types/
│ │ └── env.d.ts
│ ├── app.module.ts
│ └── index.ts
├── package.json
├── tsconfig.json
└── wrangler.jsonc

Each directory is a self-contained module. The root module composes them together:

import { Module } from 'stratal/module'
import { GuardsModule } from './guards/guards.module'
import { JobsModule } from './jobs/jobs.module'
import { MiddlewareModule } from './middleware/middleware.module'
import { NotesModule } from './notes/notes.module'
import { NotificationsModule } from './notifications/notifications.module'
@Module({
imports: [
GuardsModule,
MiddlewareModule,
NotesModule,
NotificationsModule,
JobsModule,
],
})
export class AppModule {}
FileRole
src/index.tsWorker entry point. Exports a new Stratal({ module: AppModule }) instance.
src/app.module.tsRoot module. Imports every feature module so the DI container knows about all controllers, providers, consumers, and jobs.
package.jsonLists stratal as a dependency, plus typescript, wrangler, and @cloudflare/workers-types as dev dependencies.
tsconfig.jsonEnables experimentalDecorators and emitDecoratorMetadata — both required for Stratal’s DI system.
wrangler.jsoncCloudflare Worker config. Sets the entry point (main), compatibility flags (nodejs_compat), environment variables, and bindings (KV, Queues, etc.).
  • Keep features self-contained. A feature directory should hold everything it needs — controller, service, schemas, tokens, and module. Other features interact through imports and DI, not by reaching into sibling directories.
  • Co-locate schemas with their feature. Putting validation schemas next to the controller that uses them makes them easy to find and update together.
  • Extract shared concerns into their own modules. If a guard or middleware is used by multiple features, give it its own module (e.g. guards/) and import that module where needed.
  • Use a types/ directory for ambient declarations. Module augmentation files like env.d.ts (for typing Env bindings) belong in a top-level types/ directory inside src/.
  • Avoid deeply nested directories. One level of nesting (src/notes/notes.controller.ts) is usually enough. Deeper nesting adds navigation overhead without meaningful benefit.