DEV Community

Cover image for How I build Rikta: a Zero-Config TypeScript Backend Framework
Riccardo Tartaglia
Riccardo Tartaglia

Posted on • Originally published at Medium

How I build Rikta: a Zero-Config TypeScript Backend Framework

TL;DR: Rikta is an open source TypeScript framework that eliminates NestJS "module hell" through automatic dependency injection and decorator-based routing. Built on Fastify, it delivers 32% better performance than NestJS while maintaining full type safety.

GitHub Repository https://github.com/riktaHQ/rikta
Documentation https://rikta.dev


What is Rikta and Why Does It Exist?

Rikta is an open source TypeScript backend framework designed for developers who want structure without complexity.

The Node.js ecosystem offers two extremes. On one side, you have Express: minimal, flexible, but unstructured. On the other, NestJS: enterprise-ready, but verbose and configuration-heavy.

Rikta occupies the middle ground. It provides dependency injection, decorator-based routing, and automatic validation without requiring manual module configuration.


The Problem: NestJS Module Hell

NestJS dominates enterprise Node.js development. It brings Angular-style architecture to the backend: modules, providers, dependency injection, and decorators.

But developers consistently report the same frustration: module configuration overhead.

Here is a typical NestJS module:

@Module({
  imports: [DatabaseModule, AuthModule, CacheModule],
  controllers: [UserController],
  providers: [UserService, UserRepository],
  exports: [UserService],
})
export class UserModule {}
Enter fullscreen mode Exit fullscreen mode

Every service needs explicit registration in providers. Every cross-module dependency requires imports and exports arrays. Circular dependencies trigger cryptic error messages.

The community calls this "Module Hell."

Common NestJS Pain Points

  • Verbose boilerplate: Creating a simple service requires touching multiple files
  • Circular dependency errors: The infamous "Nest can't resolve dependencies" message
  • Configuration drift: Forgetting to export a service breaks consumers silently
  • forwardRef everywhere: Workarounds become the norm, not the exception

Rikta's Solution: Zero-Config Autowiring

Rikta takes a radically different approach. You decorate a class, and the framework handles everything else.

@Injectable()
export class GreetingService {
  getGreeting(): string {
    return 'Welcome to Rikta!';
  }
}

@Controller()
export class AppController {
  @Autowired()
  private greetingService!: GreetingService;

  @Get('/')
  index() {
    return { message: this.greetingService.getGreeting() };
  }
}
Enter fullscreen mode Exit fullscreen mode

No module files. No imports/exports arrays. No providers configuration.

The framework automatically discovers decorated classes, resolves dependencies, and wires everything together at startup.


Technology Stack and Architecture Decisions

Rikta's stack reflects three core goals: performance, type safety, and developer experience.

Why Fastify Instead of Express?

Rikta builds on Fastify rather than Express. This choice stems from three factors.

1. Raw Performance

Fastify handles up to 30,000 requests per second in benchmarks. Its schema-based validation compiles JSON schemas into optimized functions at startup. Express runs validation at runtime for every request.

2. Plugin Architecture

Fastify encapsulates functionality into isolated plugins with their own dependency injection scope. This maps cleanly to Rikta's service architecture.

3. Native TypeScript Support

Fastify ships with complete type definitions. The type system uses generics for request/response typing, eliminating manual type assertions.

Why TypeScript is Mandatory

TypeScript is not optional in Rikta. The framework uses experimental decorators and reflect-metadata for dependency injection.

This enables powerful features like automatic type inference from Zod schemas:

@Post()
create(@Body(UserSchema) user: z.infer<typeof UserSchema>) {
  // TypeScript infers the user type from the Zod schema
  // Runtime validation happens automatically
}
Enter fullscreen mode Exit fullscreen mode

Why Zod for Validation

Zod schemas define both runtime validation rules and TypeScript types in a single source of truth.

This prevents the common problem of validation logic diverging from type definitions. One schema, two purposes.

Monorepo Structure

The project uses npm workspaces to manage multiple packages:

packages/
├── core/       # DI, routing, lifecycle
├── cli/        # Project scaffolding
├── swagger/    # OpenAPI documentation
└── typeorm/    # Database integration
Enter fullscreen mode Exit fullscreen mode

This structure keeps the core lightweight. Developers install only what they need.


Architecture Deep Dive

Rikta's architecture centers on three systems: dependency injection, routing, and lifecycle management.

High-Level Architecture Diagram

┌─────────────────────────────────────────────────────────────────┐
│                     APPLICATION BOOTSTRAP                        │
│                        Rikta.create()                           │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                    AUTO-DISCOVERY ENGINE                         │
│                                                                  │
│   File Scan ──▶ Decorator Detection ──▶ Metadata Extraction     │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                 DEPENDENCY INJECTION CONTAINER                   │
│                                                                  │
│   ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐      │
│   │ Singleton │ │ Transient │ │  Factory  │ │   Value   │      │
│   │   Scope   │ │   Scope   │ │  Provider │ │   Token   │      │
│   └───────────┘ └───────────┘ └───────────┘ └───────────┘      │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                      ROUTE REGISTRATION                          │
│                                                                  │
│   Controller Metadata ──▶ Route Building ──▶ Fastify Handler    │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                        FASTIFY SERVER                            │
│                 HTTP Request/Response Handling                   │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

How Dependency Injection Works in Rikta

Rikta's DI system uses TypeScript decorators and the reflect-metadata library. Here is how the pieces connect.

The @Injectable Decorator

import 'reflect-metadata';

const INJECTABLE_KEY = Symbol('injectable');

export function Injectable(): ClassDecorator {
  return (target: Function) => {
    Reflect.defineMetadata(INJECTABLE_KEY, true, target);
  };
}
Enter fullscreen mode Exit fullscreen mode

This decorator marks a class as a provider. The metadata flag tells the container this class should be managed.

The @Autowired Decorator

const AUTOWIRED_KEY = Symbol('autowired');

export function Autowired(token?: symbol): PropertyDecorator {
  return (target: Object, propertyKey: string | symbol) => {
    const type = Reflect.getMetadata('design:type', target, propertyKey);
    const autowiredDeps = Reflect.getMetadata(AUTOWIRED_KEY, target) || [];
    autowiredDeps.push({ propertyKey, type, token });
    Reflect.defineMetadata(AUTOWIRED_KEY, autowiredDeps, target);
  };
}
Enter fullscreen mode Exit fullscreen mode

This decorator records which properties need injection. It captures the property name, the type (from TypeScript's emitted metadata), and an optional token for custom providers.

Container Resolution Logic

The container performs topological sorting to resolve dependencies in the correct order.

class Container {
  private providers = new Map<Function | symbol, any>();

  resolve<T>(target: Function | symbol): T {
    // Return cached instance if exists (Singleton pattern)
    if (this.providers.has(target)) {
      return this.providers.get(target);
    }

    // Get constructor parameter types via reflect-metadata
    const paramTypes = Reflect.getMetadata('design:paramtypes', target) || [];

    // Resolve each dependency recursively
    const dependencies = paramTypes.map((type: Function) => this.resolve(type));

    // Instantiate with resolved dependencies
    const instance = new (target as any)(...dependencies);

    // Handle @Autowired properties
    const autowired = Reflect.getMetadata(AUTOWIRED_KEY, target.prototype) || [];
    for (const dep of autowired) {
      instance[dep.propertyKey] = this.resolve(dep.token || dep.type);
    }

    // Cache and return
    this.providers.set(target, instance);
    return instance;
  }
}
Enter fullscreen mode Exit fullscreen mode

Understanding Provider Scopes

Rikta supports two scopes: Singleton and Transient.

Scope Behavior Use Case
Singleton (default) One instance for entire app Services, repositories, configs
Transient New instance on every injection Request loggers, unique IDs
@Injectable({ scope: 'transient' })
export class RequestLogger {
  private requestId = crypto.randomUUID();

  log(message: string) {
    console.log(`[${this.requestId}] ${message}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Routing System Implementation

Rikta's routing wraps Fastify's router with decorator-based configuration.

Controller and Route Decorators

export function Controller(prefix: string = ''): ClassDecorator {
  return (target: Function) => {
    Reflect.defineMetadata('controller:prefix', prefix, target);
    Reflect.defineMetadata('controller', true, target);
  };
}

export function Get(path: string = ''): MethodDecorator {
  return (target, propertyKey, descriptor) => {
    const routes = Reflect.getMetadata('routes', target.constructor) || [];
    routes.push({ method: 'GET', path, handler: propertyKey });
    Reflect.defineMetadata('routes', routes, target.constructor);
  };
}
Enter fullscreen mode Exit fullscreen mode

Parameter Extraction

Rikta provides decorators for automatic request data extraction:

@Post('/users')
createUser(
  @Body() body: CreateUserDto,
  @Query('role') role: string,
  @Param('id') id: string,
  @Headers('authorization') auth: string
) {
  // Parameters are automatically extracted and injected
}
Enter fullscreen mode Exit fullscreen mode

Each decorator stores metadata about which request property to extract. The route handler reads this metadata and builds the argument list before invoking the controller method.


Validation with Zod Integration

Rikta integrates Zod validation directly into the request pipeline.

Defining Schemas

import { z } from 'zod';

export const CreateUserSchema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  name: z.string().optional(),
});

export type CreateUserDto = z.infer<typeof CreateUserSchema>;
Enter fullscreen mode Exit fullscreen mode

Automatic Request Validation

When you pass a Zod schema to the @Body decorator, Rikta validates incoming requests automatically:

@Post('/users')
createUser(@Body(CreateUserSchema) user: CreateUserDto) {
  // If validation fails: 400 Bad Request with error details
  // If validation passes: user is typed and validated
}
Enter fullscreen mode Exit fullscreen mode

The framework compiles Zod schemas into Fastify's JSON schema format at startup. This enables Fastify's optimized validation path.


Lifecycle Hooks and Event Bus

Rikta provides hooks for application lifecycle events and an internal event bus for cross-service communication.

Available Lifecycle Hooks

Hook When It Fires
OnProviderInit After provider instantiation, dependencies injected
OnApplicationBootstrap After all providers initialized, before server starts
OnApplicationShutdown When app receives termination signal
@Injectable()
export class DatabaseService implements OnProviderInit {
  async onProviderInit() {
    await this.connect();
    console.log('Database connected');
  }
}
Enter fullscreen mode Exit fullscreen mode

Event Bus for Loose Coupling

The event bus enables services to communicate without direct dependencies:

@Injectable()
export class UserService {
  @Autowired()
  private eventBus!: EventBus;

  async createUser(data: CreateUserDto) {
    const user = await this.repository.save(data);
    this.eventBus.emit('user.created', user);
    return user;
  }
}

@Injectable()
export class NotificationService {
  @OnEvent('user.created')
  async handleUserCreated(user: User) {
    await this.sendWelcomeEmail(user.email);
  }
}
Enter fullscreen mode Exit fullscreen mode

Performance: Rikta vs NestJS Benchmarks

Rikta targets NestJS performance as a baseline. Here are the benchmark results from the official repository:

Metric Improvement vs NestJS Winner
Startup Time 37.7% faster Rikta
GET Requests 44.3% faster Rikta
POST Requests 14.8% faster Rikta
Param Requests 36.7% faster Rikta
Average 32.0% faster Rikta

Why Rikta is Faster

The performance gains come from three sources:

  1. Fastify's optimized HTTP handling vs Express (NestJS default)
  2. Reduced abstraction layers in the DI system
  3. No module resolution overhead at runtime

Production Optimization Tips

For maximum performance in production:

const app = await Rikta.create({
  port: 3000,
  silent: true,    // Disable console output
  logger: false    // Disable Fastify logging
});
Enter fullscreen mode Exit fullscreen mode

Technical Challenges and Solutions

Building Rikta required solving several non-trivial technical problems.

Challenge 1: TypeScript Decorator Metadata

Problem: TypeScript's reflect-metadata requires specific compiler settings. Without emitDecoratorMetadata: true, the framework cannot read constructor parameter types.

Solution: The CLI scaffolds projects with the correct tsconfig.json:

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
Enter fullscreen mode Exit fullscreen mode

Challenge 2: Circular Dependencies

Problem: Unlike NestJS, Rikta has no module boundaries to catch circular dependencies early. Two services injecting each other creates an infinite resolution loop.

Solution: Rikta detects cycles during container initialization and throws a clear error message identifying the circular path:

Error: Circular dependency detected:
  UserService -> AuthService -> UserService
Enter fullscreen mode Exit fullscreen mode

Challenge 3: Property Injection Timing

Problem: The @Autowired decorator injects into class properties. But TypeScript initializes properties before the constructor runs. Default values overwrite injected dependencies.

Solution: Use the definite assignment assertion pattern:

@Autowired()
private greetingService!: GreetingService;  // Note the !
Enter fullscreen mode Exit fullscreen mode

The ! tells TypeScript the property will be assigned externally.

Challenge 4: Auto-Discovery Performance

Problem: Scanning the filesystem for decorated classes adds startup time. Large projects with thousands of files see noticeable delays.

Solution: Rikta scans only specific directories (src/controllers, src/services by default). Configuration allows customizing scan paths.


Best Practices Applied in Rikta

1. Single Responsibility for Decorators

Each decorator does one thing. @Injectable marks providers. @Controller marks controllers. @Get defines routes.

2. Fail Fast on Configuration Errors

Rikta validates configuration at startup, not at runtime. Missing dependencies, invalid schemas, and circular references throw immediately.

3. Minimal Core, Optional Packages

The core package includes DI, routing, and lifecycle. Database integration, Swagger documentation, and CLI tooling live in separate packages.

4. Convention Over Configuration

Default behaviors match common patterns. Controllers live in src/controllers. Services live in src/services. The default scope is Singleton.


Lessons Learned

Lesson 1: Module Systems Have Trade-offs

NestJS modules feel verbose, but they provide explicit dependency boundaries. Rikta's autowiring feels magical, but debugging requires understanding the implicit resolution order.

Neither approach is universally better. The choice depends on team size and project complexity.

Lesson 2: Decorator Magic Needs Documentation

Decorators hide implementation details. This improves developer experience when everything works. It creates confusion when things break.

Rikta invests heavily in error messages that explain what the framework expected and what it found.

Lesson 3: Fastify Plugin Scoping

Fastify plugins have encapsulation by default. A plugin registered in one scope cannot access decorators from another scope.

Rikta's global DI container works around this, but integrating with Fastify plugins requires careful scoping.

Lesson 4: TypeScript Decorators Are Experimental

The decorator specification has changed multiple times. TypeScript 5 introduced the new standard, but Rikta uses legacy decorator syntax for reflect-metadata compatibility.

This creates migration risk for future TypeScript versions.

Lesson 5: Zero-Config Has Limits

Auto-discovery works for small to medium projects. Enterprise applications with hundreds of services need explicit control over instantiation order, lazy loading, and module boundaries.

Rikta provides escape hatches, but the core design assumes simpler use cases.


Getting Started with Rikta

Install and scaffold a new project in seconds:

npx @riktajs/cli new my-app
cd my-app
npm run dev
Enter fullscreen mode Exit fullscreen mode

The CLI generates a complete project structure:

my-app/
├── src/
│   ├── controllers/
│   │   └── app.controller.ts
│   ├── services/
│   │   └── greeting.service.ts
│   └── main.ts
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

Your API runs at http://localhost:3000.


Conclusion

Rikta offers a compelling alternative for TypeScript backend development. It combines the performance of Fastify, the structure of decorator-based DI, and the simplicity of zero-config autowiring.

The framework is MIT licensed and welcomes contributions. If you are tired of NestJS module boilerplate but need more structure than Express provides, Rikta deserves your attention.

Star the repository on GitHub and try it in your next project.

GitHub Repository https://github.com/riktaHQ/rikta
Documentation https://rikta.dev

Top comments (0)