Skip to main content

From Express

Express services tend to share a recognisable shape: an app object, a stack of middleware, a router file or two, ad-hoc process-level config via process.env, and console.log for observability. None of this is wrong — Titan just trades the manual wiring for an opinionated container.

The point of this page is not to lecture you about why a framework is better; it is to show you the smallest concrete mapping between what you wrote in Express and what its equivalent looks like in Titan, so you can decide whether the trade is worth it.

At a glance

ExpressTitan
express() appApplication.create({ modules: [...] })
app.use(mw) middlewareNetron RPC middleware via netron.use(...)
app.get('/path', handler)@Service method with @Public()
req.body / req.query / req.paramsPlain method arguments
req.headersRPC context (auth, tracing) from Netron.useContext
process.envConfigModule.forRoot({ schema, sources })
console.log / winston / pinoBuilt-in LoggerService (pino under the hood)
helmet / cors / compressionTransport-level options or per-method middleware
joi / ajv / class-validatorZod schemas + @Validate(schema)
error-handler middlewareTitanError subclasses (auto-serialised)
node-crontitan-scheduler @Cron(...) decorator
prom-clienttitan-metrics counters / gauges / histograms
winston-loki / file rotationLogger transports (configured declaratively)
bull / bullmq queuestitan-notifications (rotif) or titan-scheduler
Manual redis connectiontitan-redis (clusters, sentinel, named instances)
Manual graceful shutdown handlersBuilt-in lifecycle (OnStop / OnDestroy)

A side-by-side concrete example

Suppose you have a small REST endpoint that creates a user.

// Express — index.ts
import express from 'express';
import { z } from 'zod';

const app = express();
app.use(express.json());

const CreateUserSchema = z.object({ email: z.string().email() });

app.post('/users', async (req, res, next) => {
try {
const input = CreateUserSchema.parse(req.body);
const user = await repo.create(input);
res.status(201).json(user);
} catch (e) {
next(e);
}
});

app.use((err, _req, res, _next) => {
if (err.name === 'ZodError') return res.status(400).json({ error: err.errors });
console.error(err);
res.status(500).json({ error: 'internal' });
});

app.listen(3000);
// Titan — equivalent
import { Application, Module, Service, Public, Injectable, Inject }
from '@omnitron-dev/titan';
import { Validate } from '@omnitron-dev/titan/validation';
import { z } from 'zod';

const CreateUserSchema = z.object({ email: z.string().email() });
type CreateUser = z.infer<typeof CreateUserSchema>;

@Injectable()
class UserRepo { /* ... */ }

@Service('users@1.0.0')
class UsersService {
constructor(private readonly repo: UserRepo) {}

@Public()
@Validate(CreateUserSchema)
async create(input: CreateUser) {
return this.repo.create(input);
}
}

@Module({ providers: [UserRepo, UsersService] })
class UsersModule {}

const app = await Application.create({ modules: [UsersModule] });
await app.start();

Things you stop writing yourself:

  • Body parsing. The transport layer hands you typed arguments.
  • Per-handler try/catch. Throw a typed error; the framework serialises it on the wire.
  • Status-code mapping. Errors.notFound(...) / Errors.validation(...) carry their own HTTP-equivalent codes.
  • Listen / port management. Configured declaratively on the Netron transport.

Mapping middleware

Authentication

// Express
app.use((req, res, next) => {
const token = req.headers.authorization?.replace(/^Bearer\s+/, '');
if (!token) return res.status(401).end();
req.user = verifyJwt(token);
next();
});
// Titan — use titan-auth
@Module({
imports: [TitanAuthModule.forRoot({ jwtSecret: env.JWT_SECRET })],
})
class AppModule {}

// In your service:
@Service('users@1.0.0')
class UsersService {
@Public()
@Auth({ roles: ['user'] })
async me(@Context() ctx: AuthContext) { return ctx.user; }
}

CORS / Helmet / Compression

These belong to the HTTP transport, not the service. Configure on the Netron HTTP transport directly:

import { HttpTransport } from '@omnitron-dev/titan/netron/transport-http';

netron.use('http', new HttpTransport({
port: 3000,
cors: { origin: 'https://example.com' },
// compression, helmet-style headers, etc.
}));

Logging requests

Express:

app.use(morgan('combined'));

Titan: enable the built-in logger and request middleware:

LoggerModule.forRoot({ level: 'info' });
netron.use(RequestLoggingMiddleware);

→ See Netron / Middleware for the full middleware contract.

Configuration

Replace ad-hoc process.env reads with a validated schema. You get type-safety and a single source of truth.

// Express — typical
const PORT = parseInt(process.env.PORT ?? '3000', 10);
const REDIS_URL = process.env.REDIS_URL ?? 'redis://localhost:6379';
// Titan
import { ConfigModule } from '@omnitron-dev/titan/module/config';
import { z } from 'zod';

const AppConfigSchema = z.object({
port: z.coerce.number().default(3000),
redisUrl: z.string().url(),
});

ConfigModule.forRoot({
schema: AppConfigSchema,
sources: [{ type: 'env', prefix: 'APP_' }],
});

ConfigModule

Logging

Drop console.log and winston:

// Express
console.log(`[${new Date().toISOString()}] user created`, { id });
// Titan
@Injectable()
class UsersService {
constructor(private readonly logger: LoggerService) {}
async create(input) {
const user = await this.repo.create(input);
this.logger.info({ id: user.id }, 'user created');
return user;
}
}

LoggerModule

Graceful shutdown

Express requires manual SIGTERM / SIGINT handlers and server.close() orchestration. Titan handles this automatically:

// Express
const server = app.listen(3000);
process.on('SIGTERM', () => server.close(() => process.exit(0)));
// Titan
@Injectable()
class FlushBuffersOnShutdown implements OnStop {
async onStop() { await this.bufferedWriter.flush(); }
}

The Application registers signal handlers, fires OnStopOnDestroy in reverse dependency order, and exits cleanly.

Application / Shutdown

Background work (cron, queues)

Express setupTitan equivalent
node-cron + manual handlertitan-scheduler @Cron('0 * * * *')
bullmq queue + worker processtitan-notifications rotif backbone
Manual setInterval@Interval(60_000)
setTimeout + bookkeeping@Timeout(5_000)

SchedulerModule and NotificationsModule.

  1. Bootstrap. Wrap your express() app inside an Application shell that runs alongside it — gradually move endpoints over.
  2. Config + logger. Replace process.env reads and console.log calls. Wins are immediate (type-safety + structured logs).
  3. One endpoint. Pick a low-risk endpoint, convert it to a @Service with @Public(). Front it with a thin Express proxy if needed:
    app.post('/users', async (req, res) => {
    try { res.json(await usersService.create(req.body)); }
    catch (e) { /* map TitanError → status */ }
    });
  4. Cross-cutting concerns. Replace middleware (auth, rate-limit, metrics) with Titan modules one at a time.
  5. Background work. Migrate cron / queues to the scheduler / notifications modules.
  6. Cut the cord. Once endpoints are all converted, drop the Express proxy and use Netron HTTP transport directly.

What you gain

  • Validation, errors, logging, metrics as first-class — not bolted-on.
  • Transport flexibility. Same service works over HTTP, WS, TCP, Unix without rewriting handlers.
  • Real DI. Easier tests; cleaner separation of concerns; contextual overrides for multi-tenant scenarios.
  • Operability. Built-in graceful shutdown, health probes, metrics endpoint, traceable IDs.

What you give up

  • The "one file, one app" feeling. Titan applications are structured. If your service is < 200 lines of Express, the ergonomic gain is modest.
  • Direct control over the request/response cycle. You will rarely need it, but if you do (e.g., streaming a multipart upload byte-for-byte), you reach for transport-level escapes.
  • Decorator-heavy code style. If your team is decorator-averse, Titan will feel heavy.

See also