Skip to main content

Conventions

This guide covers naming conventions and project organization patterns for Navios applications. Following these conventions ensures consistency and makes your codebase easier to navigate.

File Naming

Use lowercase with dots separating the name from the type suffix:

TypePatternExample
Modulename.module.tsuser.module.ts
Controllername.controller.tsuser.controller.ts
Servicename.service.tsuser.service.ts
Factoryname.factory.tsdatabase.factory.ts
Guardname.guard.tsauth.guard.ts
Attributename.attribute.tsrate-limit.attribute.ts
Repositoryname.repository.tsuser.repository.ts
Providersname.providers.tsuser.providers.ts
Constantsname.constants.tsuser.constants.ts

Providers File

Use name.providers.ts for bound or factory injection tokens that a module uses. These tokens pre-configure services with specific values or dynamically compute configuration:

// user.providers.ts
import { InjectionToken } from '@navios/di'
import { JwtServiceToken } from '@navios/jwt'

import { z } from 'zod'

import { AppConfig } from '../config/app.config.js'

// Let's setup JwtService that we will use in our application
export const JwtService = InjectionToken.factory(
JwtServiceToken,
async (ctx) => {
const config = await ctx.container.get(AppConfig)
return {
secret: config.getOrThrow('jwt.secret'),
signOptions: { expiresIn: config.getOrThrow('jwt.expiresIn') },
}
},
)

Use bound tokens for static values and factory tokens when you need to read from ConfigService or compute values dynamically. See the Injection Tokens guide for more details.

Services vs Factories

Understanding when to use @Injectable() services versus @Factory() is important for proper dependency management.

When to Use Services

Use @Injectable() for most application logic. Services are the default choice for:

  • Business logic and domain operations
  • Data validation and transformation
  • Coordinating between other services
  • Any class-based functionality you control
// user.service.ts
@Injectable()
class UserService {
private db = inject(DatabaseService)

async findById(id: string) {
return this.db.users.findUnique({ where: { id } })
}

async create(data: CreateUserDto) {
return this.db.users.create({ data })
}
}

When to Use Factories

Use @Factory() in these specific scenarios:

1. Initializing external libraries that aren't decorated with @Injectable

Libraries like PrismaClient, HTTP clients (@navios/http, axios), or other third-party classes need factories to be integrated into the DI system:

// database.factory.ts
import { Factory, type FactoryContext } from '@navios/di'
import { PrismaClient } from '@prisma/client'

@Factory()
class DatabaseFactory {
create(ctx: FactoryContext) {
const client = new PrismaClient()

ctx.addDestroyListener(async () => {
await client.$disconnect()
})

return client
}
}

// Usage
@Injectable()
class UserRepository {
private prisma = inject(DatabaseFactory)

findById(id: string) {
return this.prisma.user.findUnique({ where: { id } })
}
}

2. Returning different implementations based on configuration

When you need to select between providers (AI, email, payment, storage, etc.) at runtime:

// ai.factory.ts
import { Factory, InjectionToken, type FactoryContext } from '@navios/di'
import { z } from 'zod'

const aiConfigSchema = z.object({
provider: z.enum(['openai', 'anthropic']),
apiKey: z.string(),
})

interface AIService {
generateText(prompt: string): Promise<string>
}

const AI_SERVICE_TOKEN = InjectionToken.create<AIService, typeof aiConfigSchema>(
'AI_SERVICE',
aiConfigSchema,
)

@Factory({ token: AI_SERVICE_TOKEN })
class AIServiceFactory {
create(ctx: FactoryContext, config: z.infer<typeof aiConfigSchema>): AIService {
switch (config.provider) {
case 'openai':
return new OpenAIService(config.apiKey)
case 'anthropic':
return new AnthropicService(config.apiKey)
}
}
}

Quick Reference

ScenarioUse
Business logic, repositories, domain services@Injectable()
Third-party libraries (Prisma, axios, etc.)@Factory()
Provider selection (AI, email, payment)@Factory() with InjectionToken
Simple configuration-based initialization@Injectable() with schema

For detailed factory patterns, see the Factories guide.

Project Structure

A well-organized Navios project follows this structure:

src/
├── config/ # Application configuration
│ └── app.config.ts
├── modules/ # Feature modules
│ ├── user/
│ │ ├── user.module.ts
│ │ ├── user.controller.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ ├── user.providers.ts
│ │ ├── user.constants.ts
│ │ └── index.ts
│ ├── order/
│ │ ├── order.module.ts
│ │ ├── order.controller.ts
│ │ ├── order.service.ts
│ │ └── index.ts
│ └── auth/
│ ├── auth.module.ts
│ ├── auth.controller.ts
│ ├── auth.service.ts
│ ├── auth.guard.ts
│ └── index.ts
├── shared/ # Shared utilities
│ ├── guards/
│ │ └── roles.guard.ts
│ ├── attributes/
│ │ └── rate-limit.attribute.ts
│ └── index.ts
├── app.module.ts # Root module
└── main.ts # Entry point

Module Organization

Modules should be nested and self-contained:

modules/
├── user/ # User feature
│ ├── profile/ # Nested sub-feature
│ │ ├── profile.controller.ts
│ │ └── profile.service.ts
│ ├── user.module.ts
│ ├── user.controller.ts
│ └── user.service.ts
└── order/
└── ...

Barrel Exports

Each module should have an index.ts that re-exports its public API:

// modules/user/index.ts
export { UserModule } from './user.module.js'
export { UserService } from './user.service.js'
export { UserRepository } from './user.repository.js'

Endpoint Definitions

Endpoint definitions can be placed in a shared library (for front-end/back-end sharing) or in a dedicated folder within your API.

Structure

Organize endpoints to mirror your module structure. Each endpoint should be in its own file:

api/
├── user/
│ ├── item.ts # GET /users/:id
│ ├── list.ts # GET /users
│ ├── create.ts # POST /users
│ ├── update.ts # PUT /users/:id
│ ├── delete.ts # DELETE /users/:id
│ └── index.ts # Barrel export
├── order/
│ ├── item.ts
│ ├── list.ts
│ └── index.ts
└── index.ts

Naming Convention

Use action-based names for endpoint files:

FileHTTP MethodDescription
item.tsGETFetch single item
list.tsGETFetch list of items
create.tsPOSTCreate single item
update.tsPUTUpdate single item
delete.tsDELETEDelete single item

Endpoint File Structure

Each endpoint file should export the endpoint definition and related types:

// api/user/item.ts
import { builder } from '@navios/builder'

import { UserSchema } from '@myapp/schemas'
import { z } from 'zod'

const API = builder()

export const endpoint = API.declareEndpoint({
method: 'GET',
url: '/users/$userId',
responseSchema: UserSchema.pick({ id: true, name: true, email: true }),
})

// Export types for front-end usage
export type Response = z.infer<typeof endpoint.responseSchema>

Barrel Export Pattern

Use namespace exports for clean controller imports:

// api/user/index.ts
export * as Item from './item.js'
export * as List from './list.js'
export * as Create from './create.js'
export * as Update from './update.js'
export * as Delete from './delete.js'

Usage in controller:

import * as UserApi from '@myapp/api/user'

@Controller()
class UserController {
@Endpoint(UserApi.Item.endpoint)
async getUser(params: EndpointParams<typeof UserApi.Item.endpoint>) {
// ...
}

@Endpoint(UserApi.List.endpoint)
async listUsers(params: EndpointParams<typeof UserApi.List.endpoint>) {
// ...
}
}

Type Exports for Front-End

Export useful types from each endpoint for front-end consumption:

// api/user/list.ts
import { builder } from '@navios/builder'

import { UserSchema } from '@myapp/schemas'
import { z } from 'zod'

const API = builder()

const ItemSchema = UserSchema.pick({ id: true, name: true, email: true })

export const endpoint = API.declareEndpoint({
method: 'GET',
url: '/users',
querySchema: z.object({
page: z.coerce.number().optional(),
limit: z.coerce.number().optional(),
search: z.string().optional(),
}),
responseSchema: z.object({
items: z.array(ItemSchema),
total: z.number(),
page: z.number(),
}),
})

// Types for front-end
export type Response = z.infer<typeof endpoint.responseSchema>
export type Query = z.infer<typeof endpoint.querySchema>
export type Item = z.infer<typeof ItemSchema>

Schema Definitions

Define schemas for your main entities in a shared location. These schemas serve as the contract between your database and endpoint definitions.

Structure

schemas/
├── user.schema.ts
├── order.schema.ts
├── product.schema.ts
└── index.ts

Auto-Generated Schemas

If using Prisma, you can auto-generate Zod schemas using tools like zod-prisma-types:

npm install zod-prisma-types
// schemas/user.schema.ts (auto-generated or manual)
import { z } from 'zod'

export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
role: z.enum(['user', 'admin']),
createdAt: z.date(),
updatedAt: z.date(),
})

export type User = z.infer<typeof UserSchema>

Using Schemas in Endpoints

Pick, extend, or omit fields from your base schemas to create endpoint-specific types:

// api/user/create.ts
import { UserSchema } from '@myapp/schemas'

export const endpoint = API.declareEndpoint({
method: 'POST',
url: '/users',
requestSchema: UserSchema.pick({ email: true, name: true }),
responseSchema: UserSchema.omit({ updatedAt: true }),
})

export type Request = z.infer<typeof endpoint.requestSchema>
export type Response = z.infer<typeof endpoint.responseSchema>
// api/user/update.ts
import { UserSchema } from '@myapp/schemas'

export const endpoint = API.declareEndpoint({
method: 'PUT',
url: '/users/$userId',
requestSchema: UserSchema.pick({ name: true, role: true }).partial(),
responseSchema: UserSchema,
})

This approach ensures consistency between your database model and API contracts.

Configuration

Place configuration in a dedicated config/ folder at the application root.

Structure

src/
├── config/
│ └── app.config.ts # Main configuration
├── modules/
└── main.ts

Configuration Pattern

Use provideConfig with environment variables:

// config/app.config.ts
import { provideConfig } from '@navios/core'

import { z } from 'zod'

const configSchema = z.object({
port: z.coerce.number().default(3000),
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
database: z.object({
url: z.string(),
poolMin: z.coerce.number().default(2),
poolMax: z.coerce.number().default(10),
}),
jwt: z.object({
secret: z.string(),
expiresIn: z.string().default('1h'),
}),
redis: z.object({
url: z.string().optional(),
}),
})

export type AppConfig = z.infer<typeof configSchema>

export const AppConfigToken = provideConfig<AppConfig>({
load: async () => {
const config = configSchema.parse({
port: process.env.PORT,
nodeEnv: process.env.NODE_ENV,
database: {
url: process.env.DATABASE_URL,
poolMin: process.env.DATABASE_POOL_MIN,
poolMax: process.env.DATABASE_POOL_MAX,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN,
},
redis: {
url: process.env.REDIS_URL,
},
})

return config
},
})

Using Configuration

import { inject } from '@navios/di'

import { AppConfigToken } from '../config/app.config.js'

@Injectable()
class DatabaseService {
private config = inject(AppConfigToken)

connect() {
const dbUrl = this.config.getOrThrow('database.url')
// ...
}
}

Best Practices

  • Environment variables - Pass most configuration via environment variables
  • Validation - Use Zod to validate configuration at startup
  • Fail fast - Use getOrThrow for required configuration values
  • Type safety - Always define TypeScript types for your configuration

Tests Organization

Organize tests alongside source files with clear separation for e2e tests:

src/
├── modules/
│ └── user/
│ ├── user.service.ts
│ ├── user.service.spec.ts # Unit tests
│ ├── user.controller.ts
│ └── user.controller.spec.ts
├── app.module.ts
└── main.ts
test/
├── e2e/ # End-to-end tests
│ ├── user.e2e.spec.ts
│ ├── order.e2e.spec.ts
│ └── setup.ts # E2E test setup
├── factories/ # Test data factories
│ ├── user.factory.ts
│ └── order.factory.ts
├── mocks/ # Shared mocks
│ ├── database.mock.ts
│ └── auth.mock.ts
└── utils/ # Test utilities
└── test-app.ts

Unit Tests

Place unit tests next to the files they test with .spec.ts suffix:

// modules/user/user.service.spec.ts
import { TestContainer } from '@navios/core/testing'

import { UserService } from './user.service.js'

describe('UserService', () => {
let service: UserService

beforeEach(() => {
const container = new TestContainer()
// Setup mocks...
service = container.get(UserService)
})

it('should find user by id', async () => {
// ...
})
})

E2E Tests

Keep e2e tests in a separate test/e2e/ directory:

// test/e2e/user.e2e.spec.ts
import { createTestingModule } from '@navios/core/testing'

import { AppModule } from '../../src/app.module.js'

describe('User (e2e)', () => {
let app: NaviosApplication

beforeAll(async () => {
const testingModule = createTestingModule(AppModule, {
adapter: defineFastifyEnvironment(),
})
app = await testingModule.init()
})

afterAll(async () => {
await app.close()
})

it('GET /users/:id - should return user', async () => {
// ...
})
})

Vitest Configuration

Configure separate test runs for unit and e2e tests:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.spec.ts'], // Unit tests only
},
})
// vitest.e2e.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['test/e2e/**/*.e2e.spec.ts'],
setupFiles: ['test/e2e/setup.ts'],
testTimeout: 30000,
},
})

Add scripts to package.json:

{
"scripts": {
"test": "vitest",
"test:e2e": "vitest --config vitest.e2e.config.ts"
}
}

Summary

ConventionPattern
File namesname.type.ts (lowercase, dot-separated)
ModulesNested in modules/ folder
EndpointsOne file per action (item.ts, list.ts, etc.)
Endpoint exportsNamespace exports (export * as Item from './item.js')
SchemasShared folder, pick/omit for endpoints
Configconfig/ folder at app root, use provideConfig
Unit testsNext to source files (.spec.ts)
E2E testsSeparate test/e2e/ folder (.e2e.spec.ts)