Skip to content

First Steps ​

In this tutorial you'll build a small task-list API: a tasks table in SQLite, and REST routes to create, list, filter, update and delete tasks. It takes about 15 minutes, and needs nothing but Node.js - SQLite stores everything in a single file.

Along the way you'll write one class per layer - an entity in the data layer, a service in the domain layer and a controller in the API layer - and see what NodeC gives you for them.

NOTE

This tutorial is written for NodeC 1.0.0-beta9. The finished app is in the repository at apps/first-steps - you can run it from there instead of creating a new project (see Running the finished app).

Prerequisites ​

  • Node.js 22 or later, and npm
  • A terminal with curl, to try the API

1. Create the project ​

bash
mkdir my-tasks && cd my-tasks
npm init -y
npm pkg set type=module
npm install @nestjs/common @nestjs/core @node-c/core @node-c/api-http @node-c/api-rest @node-c/data-rdb @node-c/data-typeorm \
  class-transformer class-validator cookie-parser express rxjs sqlite3 typeorm
npm install --save-dev typescript

sqlite3 builds a native binding when it's installed. If npm reports that its install script was blocked, approve it and build it:

bash
npm install-scripts approve sqlite3
npm rebuild sqlite3

Add a tsconfig.json:

json
{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true,
    "strict": true,
    "strictPropertyInitialization": false,
    "target": "esnext"
  },
  "include": ["src"]
}

And these scripts to package.json:

json
"scripts": {
  "build": "tsc",
  "start": "node dist/main.js",
  "generate:datasource-files": "DATASOURCE_ENV=${DATASOURCE_ENV:-local} MODULE_NAMES=${MODULE_NAMES:-db} node node_modules/@node-c/core/dist/scripts/generateDatasourceFiles.js",
  "migration:generate": "typeorm migration:generate -d datasource-db-${DATASOURCE_ENV:-local}.js src/data/db/migrations/${MIGRATION_NAME}",
  "migration:run": "typeorm migration:run -d datasource-db-${DATASOURCE_ENV:-local}.js"
}

NodeC uses ES modules, so relative imports end in .js - also in the TypeScript sources.

2. Configure the app ​

One constants file for the module names, used everywhere below:

typescript
// src/common/definitions/common.constants.ts
export const Constants = {
  API_TASKS_MODULE_NAME: 'tasks',
  DATA_DB_MODULE_CONNECTION_NAME: 'db_connection',
  DATA_DB_MODULE_NAME: 'db',
  DOMAIN_TASKS_MODULE_NAME: 'tasks'
};

The common config - the database, and the routes anyone may call without logging in:

typescript
// src/config/config.common.ts
import * as path from 'node:path';

import { type AppConfigCommon, HttpMethod, RDBType } from '@node-c/core';

import { Constants } from '../common/definitions/common.constants.js';

export const appConfigCommon: AppConfigCommon = {
  api: {
    tasks: {
      anonymousAccessRoutes: {
        '/tasks': [HttpMethod.DELETE, HttpMethod.GET, HttpMethod.PATCH, HttpMethod.POST],
        '/tasks/bulk': [HttpMethod.POST],
        '/tasks/item/:id': [HttpMethod.GET]
      }
    }
  },
  data: {
    db: {
      connectionName: Constants.DATA_DB_MODULE_CONNECTION_NAME,
      database: 'first-steps.sqlite',
      type: RDBType.SQLite
    }
  },
  domain: {},
  general: {
    projectName: 'node-c-first-steps',
    projectRootPath: path.resolve(import.meta.dirname, '../../'),
    projectVersion: '1.0.0'
  }
};

The local profile - where the API listens. It's used whenever NODE_ENV isn't set:

typescript
// src/config/profiles/config.profile.local.ts
import { type AppConfigProfile, AppEnvironment } from '@node-c/core';

export const appConfigProfileLocal: AppConfigProfile = {
  api: {
    tasks: { allowedOrigins: ['localhost'], hostname: '0.0.0.0', port: 3000 }
  },
  general: { environment: AppEnvironment.Local }
};

And two index files that put them together:

typescript
// src/config/profiles/config.profiles.ts
export * from './config.profile.local.js';

// src/config/config.ts
export * from './config.common.js';
export * from './profiles/config.profiles.js';

TIP

The keys under api and data are module names: config.data.db configures the data module named db, and config.api.tasks the API module named tasks. See Configuration.

3. The data layer: an entity ​

The columns every table shares:

typescript
// src/data/dbBase/entity/base.db.entity.ts
import { EntitySchemaColumnOptions } from 'typeorm';

export interface DBEntity {
  createdAt: Date;
  deletedAt?: Date;
  id: number;
  updatedAt: Date;
}

export const DBEntitySchema: {
  columns: { [columnName: string]: EntitySchemaColumnOptions };
} = {
  columns: {
    createdAt: { type: 'datetime', createDate: true },
    deletedAt: { type: 'datetime', deleteDate: true },
    id: { type: 'integer', primary: true, generated: true },
    updatedAt: { type: 'datetime', updateDate: true }
  }
};

The task entity, its data service and its module:

typescript
// src/data/db/entities/tasks/tasks.entity.ts
import { EntitySchema } from 'typeorm';

import { type DBEntity, DBEntitySchema } from '../../../dbBase/entity/base.db.entity.js';

export interface DataDBTask extends DBEntity {
  done: boolean;
  title: string;
}

export const DataDBTaskEntity = new EntitySchema<DataDBTask>({
  columns: {
    ...DBEntitySchema.columns,
    done: { type: 'boolean', default: false },
    title: { type: 'varchar' }
  },
  name: 'task',
  tableName: 'tasks'
});
typescript
// src/data/db/entities/tasks/tasks.service.ts
import { Inject, Injectable } from '@nestjs/common';

import { ConfigProviderService, LoggerService } from '@node-c/core';
import { Constants, SQLQueryBuilderService } from '@node-c/data-rdb';
import { TypeORMDBEntityService, TypeORMDBRepository } from '@node-c/data-typeorm';

import { DataDBTask, DataDBTaskEntity } from './tasks.entity.js';

@Injectable()
export class DataDBTasksService extends TypeORMDBEntityService<DataDBTask> {
  constructor(
    configProvider: ConfigProviderService,
    logger: LoggerService,
    qb: SQLQueryBuilderService,
    @Inject(Constants.RDB_ENTITY_REPOSITORY) repository: TypeORMDBRepository<DataDBTask>
  ) {
    super(configProvider, logger, qb, repository, DataDBTaskEntity);
  }
}
typescript
// src/data/db/entities/tasks/tasks.module.ts
import { Module } from '@nestjs/common';

import { TypeORMDBRepositoryModule } from '@node-c/data-typeorm';

import { Constants } from '../../../../common/definitions/common.constants.js';

import { DataDBTaskEntity } from './tasks.entity.js';
import { DataDBTasksService } from './tasks.service.js';

@Module({
  imports: [
    TypeORMDBRepositoryModule.register({
      connectionName: Constants.DATA_DB_MODULE_CONNECTION_NAME,
      entityClass: DataDBTaskEntity,
      dataModuleName: Constants.DATA_DB_MODULE_NAME
    })
  ],
  providers: [DataDBTasksService],
  exports: [DataDBTasksService]
})
export class DataDBTasksModule {}

The data module connects to the database and registers every entity exported from its index file:

typescript
// src/data/db/entities/db.entities.ts
export * from './tasks/tasks.entity.js';
export * from './tasks/tasks.module.js';
export * from './tasks/tasks.service.js';
typescript
// src/data/db/db.module.ts
import { type DynamicModule, Module } from '@nestjs/common';

import { TypeORMDBModule } from '@node-c/data-typeorm';

import { Constants } from '../../common/definitions/common.constants.js';

import * as FolderData from './entities/db.entities.js';

@Module({})
export class DataDBModule extends TypeORMDBModule {
  static register(): DynamicModule {
    return TypeORMDBModule.register({
      connectionName: Constants.DATA_DB_MODULE_CONNECTION_NAME,
      folderData: FolderData,
      moduleClass: DataDBModule,
      moduleName: Constants.DATA_DB_MODULE_NAME
    });
  }
}

WARNING

Modules pick up the exports of their index file by name suffix - ...Entity, ...Module, ...Service, ...Controller. A class with another name, or one that isn't exported from the index file, is silently ignored.

4. The domain layer: a service ​

This is where your business logic goes. For now it only needs the defaults - find, findOne, create, bulkCreate, update and delete:

typescript
// src/domain/tasks/services/tasks/tasks.service.ts
import { Injectable } from '@nestjs/common';

import { DomainEntityService, LoggerService } from '@node-c/core';

import { DataDBTask } from '../../../../data/db/entities/tasks/tasks.entity.js';
import { DataDBTasksService } from '../../../../data/db/entities/tasks/tasks.service.js';

@Injectable()
export class DomainTasksService extends DomainEntityService<DataDBTask, DataDBTasksService> {
  constructor(dataEntityService: DataDBTasksService, logger: LoggerService) {
    super(dataEntityService, undefined, logger);
  }
}
typescript
// src/domain/tasks/services/tasks.services.ts
export * from './tasks/tasks.service.js';
typescript
// src/domain/tasks/tasks.module.ts
import { Global, Module } from '@nestjs/common';

import { loadDynamicModules, Constants as NodeCCoreConstants } from '@node-c/core';

import { Constants } from '../../common/definitions/common.constants.js';

import * as FolderData from './services/tasks.services.js';

const { services } = loadDynamicModules(FolderData);

@Global()
@Module({
  providers: [
    { provide: NodeCCoreConstants.DOMAIN_MODULE_NAME, useValue: Constants.DOMAIN_TASKS_MODULE_NAME },
    ...services!
  ],
  exports: [...services!]
})
export class DomainTasksModule {}

5. The API layer: a controller ​

typescript
// src/api/tasks/controllers/tasks/tasks.controller.ts
import { Controller, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';

import { RESTAPIEntityController } from '@node-c/api-rest';
import { LoggerService } from '@node-c/core';

import { DataDBTask } from '../../../../data/db/entities/tasks/tasks.entity.js';
import { DomainTasksService } from '../../../../domain/tasks/services/tasks/tasks.service.js';

@Injectable()
@Controller('tasks')
export class APITasksEntityController extends RESTAPIEntityController<DataDBTask, DomainTasksService> {
  constructor(domainEntityService: DomainTasksService, logger: LoggerService, reflector: Reflector) {
    super(domainEntityService, RESTAPIEntityController.getDefaultDtos<DataDBTask>(), logger, reflector);
  }
}
typescript
// src/api/tasks/controllers/tasks.controllers.ts
export * from './tasks/tasks.controller.js';
typescript
// src/api/tasks/tasks.module.ts
import { Module } from '@nestjs/common';

import { HTTPAPIModule, type HTTPAPIModuleOptions } from '@node-c/api-http';

import { Constants } from '../../common/definitions/common.constants.js';

import * as FolderData from './controllers/tasks.controllers.js';

@Module({})
export class APITasksModule extends HTTPAPIModule {
  static readonly moduleOptions: HTTPAPIModuleOptions = {
    folderData: FolderData,
    moduleClass: APITasksModule,
    moduleName: Constants.API_TASKS_MODULE_NAME
  };
}

This API has no logins, so it needs no IAM setup - its routes are public because they're listed in anonymousAccessRoutes. Adding logins and permissions later is covered in Authentication.

6. Put it together ​

The app module imports one of each, plus the config and logger modules:

typescript
// src/app.module.ts
import { Module } from '@nestjs/common';

import { ConfigProviderModule, type ConfigProviderModuleOptions, LoggerModule } from '@node-c/core';

import { APITasksModule } from './api/tasks/tasks.module.js';
import * as AppConfigs from './config/config.js';
import { DataDBModule } from './data/db/db.module.js';
import { DomainTasksModule } from './domain/tasks/tasks.module.js';

// the generateDatasourceFiles script reads the config options from AppModuleBase in dist/app.module.js
export class AppModuleBase {
  static readonly configProviderModuleRegisterOptions: ConfigProviderModuleOptions = {
    appConfigs: AppConfigs as unknown as ConfigProviderModuleOptions['appConfigs']
  };
}

@Module({
  imports: [
    ConfigProviderModule.register(AppModuleBase.configProviderModuleRegisterOptions),
    LoggerModule.register(),
    DataDBModule.register(),
    DomainTasksModule,
    APITasksModule.register(APITasksModule.moduleOptions)
  ]
})
export class AppModule extends AppModuleBase {}
typescript
// src/main.ts
import process from 'node:process';

import { DynamicModule } from '@nestjs/common';

import { NodeCApp } from '@node-c/core';

import { AppModule, AppModuleBase } from './app.module.js';
import { Constants } from './common/definitions/common.constants.js';

(async () => {
  await NodeCApp.start([AppModule as unknown as DynamicModule], {
    apiModulesOptions: [{ appModuleIndex: 0, apiModuleName: Constants.API_TASKS_MODULE_NAME }],
    generateOrmConfig: true,
    loadConfigOptions: AppModuleBase.configProviderModuleRegisterOptions
  });
})().then(
  () => console.info('App started.'),
  (err) => {
    console.error(err);
    process.exit(1);
  }
);

7. Create the table ​

Build the app, generate the TypeORM datasource file, then generate and run the first migration:

bash
npm run build
npm run generate:datasource-files
MIGRATION_NAME=CreateTasks npm run migration:generate
npm run build
npm run migration:run

migration:generate compares your entities with the (still empty) database and writes src/data/db/migrations/<timestamp>-CreateTasks.ts, with a CREATE TABLE "tasks" statement. The second build compiles it, because migrations run from dist. See Defining entities.

8. Run it ​

bash
npm start

The log ends with Nest application successfully started, and the API is at http://localhost:3000. In a second terminal:

bash
curl -X POST localhost:3000/tasks -H 'content-type: application/json' -d '{"data":{"title":"Read the NodeC docs"}}'
json
{"result":{"title":"Read the NodeC docs","createdAt":"2026-11-10T19:07:22.000Z","deletedAt":null,"id":1,"updatedAt":"2026-11-10T19:07:22.000Z","done":false}}

Create two more at once:

bash
curl -X POST localhost:3000/tasks/bulk -H 'content-type: application/json' -d '{"data":[{"title":"Build an app"},{"title":"Ship it","done":true}]}'

Find the ones that aren't done, get one by id, and mark the first one as done:

bash
curl -g 'localhost:3000/tasks?filters[done]=0'
curl localhost:3000/tasks/item/1
curl -X PATCH localhost:3000/tasks -H 'content-type: application/json' -d '{"data":{"done":true},"filters":{"id":1},"returnData":true}'

The first one returns the two open tasks:

json
{"result":{"page":1,"perPage":10,"items":[{"createdAt":"2026-11-10T19:07:22.000Z","deletedAt":null,"id":1,"updatedAt":"2026-11-10T19:07:22.000Z","done":false,"title":"Read the NodeC docs"},{"createdAt":"2026-11-10T19:07:22.000Z","deletedAt":null,"id":2,"updatedAt":"2026-11-10T19:07:22.000Z","done":false,"title":"Build an app"}],"more":false,"totalCount":2}}

And the update returns the updated row, because of returnData: true:

json
{"result":{"count":1,"items":[{"createdAt":"2026-11-10T19:07:22.000Z","deletedAt":null,"id":1,"updatedAt":"2026-11-10T19:07:22.000Z","done":true,"title":"Read the NodeC docs"}]}}

Search, sort and pick fields:

bash
curl -g 'localhost:3000/tasks?filters[title][$like]=%25app%25&orderBy[id]=desc&select[]=id&select[]=title'

Delete one - it's a soft delete, so it disappears from the results but stays in the table with a deletedAt:

bash
curl -X DELETE localhost:3000/tasks -H 'content-type: application/json' -d '{"filters":{"id":3}}'
curl -g 'localhost:3000/tasks?findAll=true'

And send something invalid, to see the validation:

bash
curl -X POST localhost:3000/tasks -H 'content-type: application/json' -d '{}'
json
{"error":"data should not be null or undefined\ndata must be an object\ndata must be a non-empty object","statusCode":400}

TIP

Pass -g to curl for URLs with [...] in them - otherwise curl treats the brackets as a glob pattern and doesn't send the request.

WARNING

Query string values are strings, and SQLite doesn't match the string false against a boolean column - use 0 and 1 for booleans in filters.

What just happened ​

  • The data service turned the request's filters, orderBy, select and pagination into SQL - see Query filters.
  • The domain service passed each call to its data service. This is where you'll add business rules - see Domain services.
  • The controller validated every request against its DTO, and mapped errors to { error, statusCode } - see Controllers.
  • Around all of it, the API module ran the request pipeline - see Request lifecycle.

Running the finished app ​

From a clone of the repository:

bash
npm install
npm run build:clean:all
cd apps/first-steps
npm run generate:datasource-files
npm run migration:run
npm start

Next steps ​


Previous: Introduction · Next: Layers and modules

Last updated: