Disciplines · Reference

Adding a New Service

Adding a new service involves:

12sections1 minread

On this page

This guide walks through the process of adding a new service to the Oshun monorepo.

Overview#

Adding a new service involves:

  1. Creating the application project
  2. Setting up the project configuration
  3. Implementing the service
  4. Adding API contracts
  5. Setting up tests
  6. Configuring deployment

Step 1: Create the Application#

Using Nx Generator#

bash
# Create a new Node.js application
nx g @nx/node:application my-service --directory=apps/{domain}/my-service

Replace {domain} with the appropriate domain (e.g., iris, lilith, yemaya, isis, sophia, hathor, bellona, tara, veritas, psyche, nyx, aja, or aphrodite).

Manual Creation#

Create the directory structure:

text
apps/{domain}/my-service/
├── src/
│   ├── app.ts
│   ├── main.ts
│   └── routes/
├── project.json
├── package.json
├── tsconfig.json
├── Dockerfile
└── .env.example

Step 2: Configure project.json#

Create apps/{domain}/my-service/project.json:

json
{
  "name": "@{domain}/my-service",
  "$schema": "../../../node_modules/nx/schemas/project-schema.json",
  "sourceRoot": "apps/{domain}/my-service/src",
  "projectType": "application",
  "tags": ["scope:{domain}", "type:app", "platform:node"],
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "outputs": ["{projectRoot}/dist"],
      "options": {
        "cwd": "apps/{domain}/my-service",
        "command": "tsc"
      }
    },
    "dev": {
      "executor": "nx:run-commands",
      "options": {
        "cwd": "apps/{domain}/my-service",
        "command": "tsx watch src/main.ts"
      }
    },
    "serve": {
      "executor": "nx:run-commands",
      "options": {
        "cwd": "apps/{domain}/my-service",
        "command": "node dist/main.js"
      }
    },
    "test": {
      "executor": "@nx/vite:test",
      "options": {
        "config": "apps/{domain}/my-service/vitest.config.ts"
      }
    },
    "lint": {
      "executor": "@nx/eslint:lint",
      "outputs": ["{options.outputFile}"]
    },
    "typecheck": {
      "executor": "nx:run-commands",
      "options": {
        "cwd": "apps/{domain}/my-service",
        "command": "tsc --noEmit"
      }
    }
  }
}

Step 3: Create package.json#

json
{
  "name": "@{domain}/my-service",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/main.ts",
    "build": "tsc",
    "start": "node dist/main.js",
    "test": "vitest run",
    "lint": "eslint src --ext ts",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@hono/node-server": "catalog:",
    "hono": "catalog:",
    "@oshun/config": "workspace:*",
    "@oshun/logging": "workspace:*",
    "@oshun/errors": "workspace:*",
    "@oshun/health": "workspace:*"
  },
  "devDependencies": {
    "@types/node": "catalog:",
    "tsx": "catalog:",
    "typescript": "catalog:",
    "vitest": "catalog:"
  }
}

Step 4: Implement the Service#

Entry Point (src/main.ts)#

typescript
import { serve } from '@hono/node-server';
import { createApp } from './app';
import { createLogger } from '@oshun/logging';

const logger = createLogger({ service: 'my-service' });

const app = createApp();

const port = parseInt(process.env.PORT || '3000', 10);

serve(
  {
    fetch: app.fetch,
    port,
  },
  (info) => {
    logger.info(`Server running on http://localhost:${info.port}`);
  }
);

Application (src/app.ts)#

typescript
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { healthRoutes } from './routes/health';
import { apiRoutes } from './routes/api';
import { errorMiddleware } from '@oshun/errors';

export function createApp() {
  const app = new Hono();

  // Middleware
  app.use('*', cors());
  app.use('*', errorMiddleware());

  // Routes
  app.route('/health', healthRoutes);
  app.route('/api', apiRoutes);

  return app;
}

Health Routes (src/routes/health.ts)#

typescript
import { Hono } from 'hono';

export const healthRoutes = new Hono();

healthRoutes.get('/', (c) => c.json({ status: 'ok' }));
healthRoutes.get('/ready', (c) => c.json({ status: 'ready' }));
healthRoutes.get('/live', (c) => c.json({ status: 'live' }));

Step 5: Add API Contract#

Create OpenAPI spec at libs/openapi/specs/{domain}/my-service.yaml:

yaml
openapi: 3.1.0
info:
  title: My Service API
  version: 1.0.0
  description: API for my service

servers:
  - url: http://localhost:3000
    description: Development server

paths:
  /health:
    get:
      summary: Health check
      operationId: getHealth
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  /api/items:
    get:
      summary: List items
      operationId: listItems
      responses:
        '200':
          description: List of items
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Item'

components:
  schemas:
    Item:
      type: object
      required:
        - id
        - name
      properties:
        id:
          type: string
        name:
          type: string

Step 6: Add Tests#

Create apps/{domain}/my-service/vitest.config.ts:

typescript
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    include: ['src/**/*.{test,spec}.ts'],
  },
});

Create a test file src/app.test.ts:

typescript
import { describe, it, expect } from 'vitest';
import { createApp } from './app';

describe('App', () => {
  const app = createApp();

  describe('GET /health', () => {
    it('should return ok status', async () => {
      const res = await app.request('/health');
      expect(res.status).toBe(200);

      const body = await res.json();
      expect(body.status).toBe('ok');
    });
  });
});

Step 7: Create Dockerfile#

dockerfile
FROM node:20-alpine AS builder

WORKDIR /app

# Copy workspace files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY libs/shared/ ./libs/shared/
COPY apps/{domain}/my-service/ ./apps/{domain}/my-service/

# Install dependencies
RUN corepack enable && pnpm install --frozen-lockfile

# Build
RUN pnpm --filter @{domain}/my-service build

FROM node:20-alpine AS runner

WORKDIR /app

COPY --from=builder /app/apps/{domain}/my-service/dist ./dist
COPY --from=builder /app/apps/{domain}/my-service/package.json ./

RUN corepack enable && pnpm install --prod --frozen-lockfile

ENV NODE_ENV=production
EXPOSE 3000

CMD ["node", "dist/main.js"]

Step 8: Environment Variables#

Create .env.example:

bash
# Server
PORT=3000
NODE_ENV=development

# Logging
LOG_LEVEL=debug

# Database (if needed)
DATABASE_URL=postgresql://user:password@localhost:5432/mydb

# Add other environment variables as needed

Step 9: Register in CI#

Add the service to .github/workflows/ci.yml if needed:

yaml
jobs:
  build:
    # ... existing config
    steps:
      - name: Build my-service
        run: nx build @{domain}/my-service

Checklist#

Before submitting your PR, ensure:

  • Project compiles without errors
  • All tests pass
  • OpenAPI spec is valid
  • Dockerfile builds successfully
  • Environment variables are documented
  • README is updated (if needed)

Best Practices#

  1. Use shared libraries - Don't duplicate code that exists in libs/shared/
  2. Follow naming conventions - Use the established patterns
  3. Add health checks - Every service should have health endpoints
  4. Document APIs - Keep OpenAPI specs up to date
  5. Write tests - Aim for good test coverage
  6. Handle errors properly - Use the shared error handling utilities
  7. Log appropriately - Use structured logging from @oshun/logging