Documentation/titan/Method Traits
In this section

Method Traits

A "method trait" is a cross-cutting concern applied via decorator. Most methods have one trait or none; some methods stack several.

Two ways to express the same thing

@Public(options) accepts the same configuration that @Auth, @RateLimit, @Cache, and @Audit set individually. The two styles are equivalent.

Inline (single decorator):

@Public({
  auth:      { roles: ['admin'] },
  rateLimit: { limit: 10, window: 60_000 },
  cache:     { ttl: 30_000 },
})
async dangerousOp(input: Input) { /* … */ }

Composed (stacked decorators):

@Public()
@Auth({ roles: ['admin'] })
@RateLimit({ limit: 10, window: 60_000 })
@Cache({ ttl: 30_000 })
async dangerousOp(input: Input) { /* … */ }

Pick a style per project. Inline is terser; composed reads top-down and groups easily.

The order rule (when composing)

Decorators evaluate outside-in at declaration; they execute inside-out at call time.

@Public()                                              //  ← outer, processes last
@Auth({ roles: ['admin'] })                            //
@RateLimit({ limit: 10, window: 60_000 })              //
@Validate({ input: InputSchema })                      //  ← inner, processes first
async dangerousOp(input: Input) { /* … */ }

@Validate runs first, then @RateLimit, then @Auth, then @Public finalises the dispatch. The runtime order is critical for stacks like "validate before rate-limit" so malformed requests don't consume rate budget.

Common stacks

Public read endpoint

@Public({
  cache:     { ttl: 30_000 },
  rateLimit: { limit: 100, window: 60_000 },
})
@Validate({ input: IdSchema })
async findById(id: string) { /* … */ }
  • Cache outermost — cache hits skip everything else.

  • Rate-limit before validation.

Authenticated write endpoint

@Public({
  auth:      { scopes: ['orders:write'] },
  rateLimit: { limit: 10, window: 60_000 },
})
@Validate({ input: CreateOrderSchema })
async create(input: z.infer<typeof CreateOrderSchema>) { /* … */ }
  • Rate limit applies to all callers (authenticated or not).

  • Auth enforces a scope.

Admin-only endpoint

@Public({ auth: { roles: ['admin'] } })
@Validate({ input: InputSchema })
async resetDatabase(input: Input) { /* … */ }

No rate limit — admin operations are deliberate and infrequent. No cache — admin operations should always run.

Internal method (not RPC)

// No @Public — not exposed.
@Retry({ attempts: 3, delay: 100 })
async fetchFromUpstream(id: string) { /* … */ }

Internal methods use @Retry, @Memoize, @Timeout (from /decorators/utility.ts), etc. — but not @Public, @Auth, or @RateLimit, which only make sense at the network edge.

Combining lifecycle decorators

@PostConstruct and @PreDestroy mark methods for lifecycle phases. They do not stack with method traits — the marked method is executed during the lifecycle, not as a wrapper for a regular call.

// Wrong — @Memoize on a lifecycle hook.
@PostConstruct()
@Memoize()
async warm() { /* … */ }

If you need memoisation in lifecycle work, extract the memoised helper:

@PostConstruct()
async warm() {
  this.dictionary = await this.loadDictionary();
}

@Memoize()
private async loadDictionary() { /* … */ }

Custom traits via createMethodInterceptor

If you need a trait the built-ins don't cover, write your own:

import { createMethodInterceptor } from '@omnitron-dev/titan/decorators';

const AuditLog = createMethodInterceptor<{ resource: string }>(
  'AuditLog',
  async (originalMethod, args, context) => {
    const result = await originalMethod(...args);
    auditLog.append({
      resource: context.options?.resource,
      method:   context.propertyKey,
      args,
      at: Date.now(),
    });
    return result;
  },
);

Use:

@Public()
@AuditLog({ resource: 'orders' })
async create(input: Input) { /* … */ }

Custom interceptors stack inside-out with the built-ins.

Class-level vs method-level

Many traits also support class-level application — they apply to every @Public method on the class:

@Service('orders@1.0.0')
@Auth({ scopes: ['orders:*'] })                          // class-level
@RateLimit({ limit: 100, window: 60_000 })               // class-level
class OrdersService {
  @Public() async list() { /* inherits class-level traits */ }

  @Public()
  @Auth({ scopes: ['orders:write'] })                    // overrides class-level
  async create(input: CreateInput) { /* … */ }
}

Method-level decorators override class-level ones for that method.

Anti-patterns

  • Cache outside an auth-gated method. Cache hits skip the auth check — anyone who can warm the cache can read its contents. Put @Auth outside @Cache, or cache per-user with a keyGenerator that includes the user identity.

  • Rate limit inside auth. Unauthenticated abusers hit your auth backend at full rate. Rate-limit before auth (or apply rate-limit class-level so it covers all methods).

  • Validate after rate limit on cheap endpoints. Malformed requests should be rejected before consuming rate budget — @Validate belongs inside (executed first).

  • Stacking similar decorators. @Cache and @Memoize together cache the same call twice. Pick one — @Cache for shared, @Memoize for per-instance.

→ Back to Decorators.