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)