Design Principles
Every framework has a few load-bearing decisions. Titan has seven. They are not negotiable inside the framework — they are how the framework was designed, why the APIs look the way they do, and what changes when you build on top of it.
1. Explicitness over magic
Every dependency is declared. Every provider is registered. No filesystem auto-discovery. No annotation scanning at startup. No "convention over configuration" that surprises you a year later when you grep the codebase and cannot find why a class is being instantiated.
// Yes — explicit. The container knows about UsersService because
// AppModule said so.
@Module({ providers: [UsersService] })
export class AppModule {}
// No — implicit. Titan does not scan ./services/*.ts at boot.
The cost: a little more typing. The benefit: a Titan codebase is
searchable. Every wiring decision is a literal string in a literal
file. New team members can answer "where does this come from?" with
grep, not by reading the framework source.
Why this matters. The most expensive bugs in mature codebases are ambient-state bugs — a function reads a global, the global was set three layers up by an init hook nobody remembers writing, the test doesn't trigger that hook, and the test passes. Titan's container makes ambient state structurally impossible: if a service needs something, it appears in its constructor.
2. Pay only for what you use
The base framework imports nothing it does not need. The 14 ecosystem modules are independently versioned npm packages; you install the ones you use. A Titan app with one service and no opt-in modules has no hidden allocator, no ambient tracing, no global instrumentation.
// A complete Titan service — no modules, no logging, no metrics.
// This works. It will not pull in pino, redis, or any other dependency.
@Service('echo@1.0.0')
export class EchoService {
@Public()
async ping(msg: string) { return msg; }
}
@Module({ providers: [EchoService] })
export class AppModule {}
await (await Application.create(AppModule)).start();
The cost: you assemble what you want. The benefit: small services stay small. Large services do not pay the small-service tax of carrying features they don't use.
3. The same primitives operate the system
There is no separate "operator API" that drifts from the runtime API.
The Omnitron CLI talks to a running Titan app over the same Netron
protocol your browser uses. The web console reads the same metrics
your services emit. The orchestrator polls the same /healthz your
load balancer probes.
This means three things in practice:
- You do not learn two languages. The wire format your frontend speaks is the wire format your CLI speaks.
- You do not maintain two surfaces. Adding an operator action is
adding a
@Publicmethod on the orchestrator service. - Your runtime is debuggable from any client. A Titan service can be inspected, called, and audited from the same code paths your product uses.
4. The contract is a TypeScript type
The wire contract between the server and any caller — browser, mobile, service-to-service, CLI — is the TypeScript service interface itself. There is no codegen step. No OpenAPI spec to keep in sync. No protobuf compilation.
// Server — the contract is the class.
@Service('users@1.0.0')
export class UsersService {
@Public()
async findById(id: string): Promise<User | null> { … }
}
// Client — the same type, imported from a shared package.
const users = await client.queryInterface<UsersService>('users@1.0.0');
const user = await users.findById('u_42');
// ^? User | null — the *server's* return type
A signature change on the server fails the build on every caller in
the same tsc invocation. Drift is a compile error, not a 3 AM
production page.
5. Errors are values
Service methods that can fail throw typed errors (TitanError and
its subclasses), each carrying an HTTP-style status, a structured
details payload, and a stable code. The client receives the same
typed error with the same payload.
// Server — typed failure.
import { Errors, ErrorCode, TitanError } from '@omnitron-dev/titan/errors';
@Public()
async findById(id: string): Promise<User> {
const user = await this.repo.findById(id);
if (!user) throw Errors.notFound('user', id);
return user;
}
// Client — discriminate by code (or by class for AuthError /
// PermissionError / RateLimitError subclasses).
try {
await users.findById('missing');
} catch (e) {
if (e instanceof TitanError && e.code === ErrorCode.NOT_FOUND) {
/* handle */
}
}
Native Error instances bubbling out of your code are wrapped with
ErrorCode.INTERNAL_ERROR (status 500) and logged on the server
with the original stack. The client never sees an opaque 500 with no
structure.
6. Lifecycle is observable
Every service can declare onInit, onStart, onStop, onDestroy.
The container fires them in dependency order — services with more
deps initialise after their dependencies, shut down before them. A
failure in any phase aborts the corresponding transition with a typed
error. There is no "half-initialised" state where some services are
ready and some are not.
@Service('users@1.0.0')
export class UsersService implements OnInit, OnStop {
async onInit() { await this.cache.warm(); }
async onStop() { await this.queue.flush(); }
}
Shutdown runs lifecycle hooks in reverse order with a global
gracefulShutdownTimeout watchdog. A hard exit guarantees no zombie
processes.
7. The container is the single source of truth
Every Titan-managed object is owned by the Nexus container. The container resolves dependencies, manages scopes, applies middleware, fires lifecycle hooks. Three corollaries:
- No globals. A "global service" is a singleton-scoped provider in the container.
- No instance-leaking. Tests instantiate a fresh container per test; nothing carries between them.
- One place to look. When you ask "what does this app contain?", the answer is "everything the container holds, in the topology declared by the modules."
The shape these decisions produce
A Titan app, viewed from outside, is:
- A single
Applicationobject that owns everything. - A tree of modules with declared imports / exports / providers.
- A set of services exposed over Netron.
- A set of optional ecosystem modules (config, logger, db, cache, etc.).
- Zero ambient state.
A Titan app, viewed from inside a service method, is:
thisis the service instance, constructor-injected with its deps.- Method parameters are validated against the schema you declared.
- The trace context is attached to the current async scope.
- The logger reachable via
this.loggeris bound to the service name. - Errors thrown become typed responses on the wire.
That is the whole framework. Read on for the mechanics.
→ Next: Architecture — how these principles compose at runtime.