Services
A Netron service is a TypeScript class that the framework binds to a named, versioned identifier on the wire.
@Service('name@version')
@Service('users@1.0.0')
class UsersService {
// …
}
The identifier is name@semver. Together they form the service's
public identity — what clients pass to queryInterface.
The version is part of the identity, not an implementation detail.
users@1.0.0 and users@2.0.0 are distinct services that can
coexist on the same app, expose different method signatures, and be
called by clients pinning to one or the other.
@Service('users@1.0.0')
class UsersServiceV1 { /* legacy */ }
@Service('users@2.0.0')
class UsersServiceV2 { /* new */ }
// Both classes registered; both reachable.
@Module({ providers: [UsersServiceV1, UsersServiceV2] })
class AppModule {}
This is the canonical way to do API versioning. Clients pin to the version they were written for; servers can run multiple versions side-by-side until clients migrate.
@Public() — opt-in exposure
Methods are private to the process by default. @Public() marks one
as RPC-callable.
@Service('users@1.0.0')
class UsersService {
@Public()
async findById(id: string): Promise<User | null> { /* exposed */ }
@Public()
async create(input: CreateInput): Promise<User> { /* exposed */ }
private async hash(password: string) { /* internal — not callable */ }
}
This is a deliberate friction point. Many frameworks expose every
public method automatically; Netron does not. The reason: methods
that look fine inside the process can be dangerous when exposed
(internal-only operations, helper methods that bypass validation,
deprecated routes). @Public() requires an explicit decision per
method.
Service descriptors
When you register a @Service class, Netron extracts service
metadata — a record built from reflect-metadata design types:
{
name: 'users',
version: '1.0.0',
methods: {
findById: {
type: 'Promise', // design:returntype name
arguments: [{ index: 0, type: 'String' }],
},
create: { /* … */ },
},
properties: { /* @Public properties, if any */ },
}
(Reflection records the runtime type names, not full schemas — e.g.
Promise, String, Object. Generic type arguments are erased.)
This metadata is what queryInterface<T>() resolves against. The
generic <T> is your TypeScript interface, supplied client-side;
the wire carries the metadata above.
The descriptor metadata is stamped on the class by the @Service
decorator (via reflect-metadata) and read back when the service is
exposed. The local peer is reachable through the running Netron
instance:
const peer = app.netron?.getLocalPeer(); // app.netron may be undefined before start()
const names = peer?.getServiceNames(); // qualified names of exposed services
The Omnitron console exposes the same metadata via the operator service.
Peers
Netron's runtime model has two peer types:
LocalPeer— represents the current process. Owns the set of registered services. One perApplication.RemotePeer— a proxy to another process. Calls on the proxy marshal as RPC packets.
The application's LocalPeer is accessible via
app.netron?.getLocalPeer(). You rarely interact with it directly —
the framework wires services on its behalf.
RemotePeer is the client-side view. netron.connect(address)
returns one; you get the typed proxy via its
queryInterface<T>('name@version').
Local vs remote calls — the same code
A service in the same process is callable as a normal method — no RPC involved:
@Service('orders@1.0.0')
class OrdersService {
constructor(private readonly users: UsersService) {}
@Public()
async create(userId: string, items: Item[]) {
const user = await this.users.findById(userId); // direct method call
if (!user) throw Errors.notFound('user', userId);
// …
}
}
The framework detects that users is a local provider and routes
calls directly. No serialisation, no transport, no middleware. The
same code from outside the process goes through the wire.
This means you write services the same way regardless of where they are called from. The wire-vs-local optimisation is invisible.
Multiple services per class? No.
One class = one service = one identifier. If a class has methods that belong to different services, split it into two classes.
This is intentional. Lumping unrelated methods into one service makes the wire contract less coherent and complicates versioning.
Service-level decorators
In addition to method-level decorators, you can apply @Auth at the
class level — it then covers every @Public method on the class:
import { Service, Public, Auth } from '@omnitron-dev/titan/decorators';
@Service('orders@1.0.0')
@Auth({ scopes: ['orders:read'] }) // class-level auth
class OrdersService {
@Public() async list() { /* … */ }
@Public() async get(id: string) { /* … */ }
@Public()
@Auth({ scopes: ['orders:write'] }) // override for this method
async create(input: CreateInput) { /* … */ }
}
Method-level @Auth overrides the class-level config for that
method (use the inherit / override flags on AuthConfig for
finer control). To allow anonymous access on one method, declare
@Auth({ allowAnonymous: true }).
@Authis the only decorator that combines this way at the class level.@RateLimit,@Cache, and@Public's other options are method-level only.
Anti-patterns
- Mutable service state. Singleton services are shared across
calls. State stored in
thisis shared state, with all the concurrency hazards that implies. Keep service instance state to references (database, cache, configuration); per-call state belongs in method-local variables or aRequest-scoped context. - Decorating private methods with
@Public. Private methods are not meant to be called from outside. If you need a public method, rename it and make it part of the contract. - Mixing service identities in one class. "UsersService" and "AdminService" should be two classes, even if they share helpers. The wire contract is per-class.
- Skipping the version.
@Service('users')leaves the version empty (the qualified name is justusers, notusers@1.0.0). That's a valid identity, but clients then resolve'users'with no version pin. Always write the explicit version.
→ Next: Transports.