Quick Implementation of DI/DI Container on SAP CAP: Node.js Hands-on Part

Estimated read time 17 min read

Introduction

In the theory part, the basic concepts of Dependency Injection (DI) and DI containers are covered. Now, let’s put that knowledge into practice. This guide assumes you are familiar with CAP, TypeScript, and using VSCode.

Project Folder Structure

To make this hands-on tutorial accessible and easy to follow for many CAP users, the following features have been incorporated:

Created a project based on the official CAP sample, bookshop.Simplified the directory structure by not creating subdirectories within the srv and test directories.Focused on DI/DI containers, omitting other design patterns and unnecessary layers.Used any type in some places to reduce code volume (not recommended for actual development).Used CSV file data and mock data instead of an external database.Registered only the repository layer in the DI container.

Let’s get started!

Creating a CAP Project

First, follow the CAP Getting Started guide to create a project.

npm add -g @sap/cds-dk

Once you have installed the cds toolkit, run the following commands:

# Check if cds is installed correctly
cds

# Create a cds project
cds init bookshop-di

# Open the bookshop directory in VSCode
code bookshop-di

For Mac users, refer to the following to enable the code command:

Assumes you activated the code command on macOS as documented

Installing Required Packages

Install the packages needed for the project. You will use the InversifyJS library for dependency injection (DI).

# TypeScript
npm install -g typescript ts-node

# InversifyJS
npm install inversify reflect-metadata

# Jest
npm install –save-dev jest ts-jest @types/jest

With the necessary packages installed, let’s look at the code in each directory.

Root Directory

To use InversifyJS, create a tsconfig.json file with experimentalDecorators and emitDecoratorMetadata set to true.

// tsconfig.json
{
“compilerOptions”: {
“target”: “es6”,
“module”: “commonjs”,
“strict”: true,
“esModuleInterop”: true,
“skipLibCheck”: false,
“forceConsistentCasingInFileNames”: true,
“experimentalDecorators”: true,
“emitDecoratorMetadata”: true
},
“include”: [“srv/**/*”, “test/**/*”],
“exclude”: [“node_modules”]
}

This is the configuration file for Jest, which you will use for unit testing.

// jest.config.js
module.exports = {
preset: “ts-jest”,
testEnvironment: “node”,
};

db Directory

You will use simplified entities and data based on the bookshop example.

Here is the entity definition in the CDS file:

// db/schema.cds
entity Books {
key ID : Integer;
title : localized String(111) @mandatory;
stock : Integer;
}

Here is the data in the CSV file:

# db/data/Books.csv
ID,title,stock
201,Wuthering Heights,101,
207,Jane Eyre,107

srv Directory

Here is the type definition for Book:

// srv/book.ts
export interface Book {
ID: number;
title: string;
stock: number;
}

Here is the CDS file:

// srv/catalogService.cds
using {Books as DbBooks} from ‘../db/schema’;

service CatalogService {
entity Books as projection on DbBooks;

action submitOrder(book : Integer, quantity : Integer) returns Books;
}

Next, you will look at the repository, handler, service, and DI container in relation to DI.

First, let’s explain IBookRepository. You define findBookById and updateBookStock to be used in the business logic of submitOrderHandler. In the business logic, findBookById retrieves the target book, checks if there is enough stock to fulfill the order, and if so, updates the stock using updateBookStock.

// srv/iBookRepository.ts
import { Book } from “./book”;

export interface IBookRepository {
findBookById(ID: number): Promise<Book | undefined>;
updateBookStock(ID: number, newStock: number): Promise<void>;
}

Next is the concrete class, where you implement findBookById and updateBookStock. Here, you use the injectable decorator provided by the Inversify library, which allows the DI container to receive class-emitted metadata.

// srv/bookRepository.ts
import { IBookRepository } from “./iBookRepository”;
import { Book } from “./book”;
import { injectable } from “inversify”;

@injectable()
class BookRepository implements IBookRepository {
async findBookById(ID: number): Promise<Book | undefined> {
return await SELECT.from(“Books”, ID, (b: Book) => ({
ID: b.ID,
stock: b.stock,
title: b.title,
}));
}

async updateBookStock(ID: number, newStock: number): Promise<void> {
await UPDATE(“Books”, ID).with({ stock: newStock });
}
}

export { BookRepository };

Next, let’s explain submitOrderHandler.

The key point here is that you do not instantiate BookRepository within this function but instead depend on the interface, IBookRepository using an argument. This allows submitOrderHandler to use any repository that implements IBookRepository, enabling us to use BookRepository in the local environment and MockBookRepository during unit testing. This loose coupling improves testability.

Note: Switching between development and production data sources can be done using CAP’s features in package.json without using a DI container.

// srv/submitOrderHandler.ts
import { Request } from “@sap/cds”;
import { IBookRepository } from “./iBookRepository”;

export const submitOrderHandler =
(bookRepository: IBookRepository) =>
// To keep the code simple for this hands-on, you use the cds Request type without defining the request type.
async (req: Request) => {
const { book: ID, quantity } = req.data;

const book = await bookRepository.findBookById(ID);
if (!book) {
return req.error(404, `Book #${ID} doesn’t exist`);
}

if (quantity < 1) {
return req.error(400, `quantity has to be 1 or more`);
}
if (quantity > book.stock) {
return req.error(409, `${quantity} exceeds stock for book #${ID}`);
}
// To keep the code simple for this hands-on, you omit the setter.
book.stock -= quantity;
await bookRepository.updateBookStock(ID, book.stock);
return book;
};

In CatalogService, you define bookRepository and pass it as an argument to submitOrderHandler. According to CAP specifications, catalogService.ts (and catalogService.cds) serves as a route layer that exposes APIs based on its information, so you do not need to inject this service class, nor do you need the injectable decorator.

// srv/catalogService.ts
import { IBookRepository } from “./iBookRepository”;
import { container } from “./inversify.config”;
import { submitOrderHandler } from “./submitOrderHandler”;
import { TYPES } from “./types”;
import cds from “@sap/cds”;

class CatalogService extends cds.ApplicationService {
async init() {
const bookRepository = container.get<IBookRepository>(
TYPES.IBookRepository
);

this.on(“submitOrder”, submitOrderHandler(bookRepository));
}
}

export default CatalogService;

Finally, you configure the DI container. You might wonder how submitOrderHandler and CatalogService call BookRepository without having concrete classes. The DI container solves this issue, as its name suggests.

Here are the actual codes for the two files. First, you define symbols (unique identifiers).

// srv/types.ts
export const TYPES = {
IBookRepository: Symbol.for(“IBookRepository”),
};

In the DI container, you bind the identifier TYPES.IBookRepository to the BookRepository class. This way, when the container requests TYPES.IBookRepository, it calls the concrete class BookRepository.

// srv/inversify.config.ts
import { Container } from “inversify”;
import { IBookRepository } from “./iBookRepository”;
import { BookRepository } from “./bookRepository”;
import { TYPES } from “./types”;

export const container = new Container();
container.bind<IBookRepository>(TYPES.IBookRepository).to(BookRepository);

Note: You can use the DI container without symbols. Specifically, you can replace TYPES.IBookRepository in the bind argument with the BookRepository class or the string ‘IBookRepository’. However, the former is not recommended because CatalogService calling the container would know the details of BookRepository, and the latter cannot guarantee uniqueness.

By using symbols, you can provide interface implementations in a way that the dependent class is no…

With the API ready, start the local host and test it with the following command:

cds-ts w

With the server running on port 4004, send a POST request defined in the http file. If “Send Request” does not appear in the http file, install the “Rest Client” extension.

Note: Although you write the UPDATE process in BookRepository, the action defined in the CDS file is considered a POST process.

// test/requests.http
###
POST http://localhost:4004/odata/v4/catalog/submitOrder
Content-Type: application/json

{ “book”:201, “quantity”:5 }

If you get a 200 OK response, you have succeeded and can move on to the next part. If an error occurs, check if the file are named correctly and saved.

test Directory

Next is the unit test part, where you can enjoy the benefits of DI.

First, define testContainer in the same format as srv/inversify.config.ts. Note that this time you bind to MockBookRepository.

// test/inversify.test.config.ts
import { Container } from “inversify”;
import { IBookRepository } from “../srv/iBookRepository”;
import { TYPES } from “../srv/types”;
import { MockBookRepository } from “./mockBookRepository”;

export const testContainer = new Container();
testContainer
.bind<IBookRepository>(TYPES.IBookRepository)
.to(MockBookRepository);

In MockBookRepository, you use mock data for books as shown below.

// test/mockBookRepository.ts
import { injectable } from “inversify”;
import { IBookRepository } from “../srv/iBookRepository”;
import { Book } from “../srv/book”;

@injectable()
class MockBookRepository implements IBookRepository {
private books: Book[] = [
{ ID: 1, title: “Test Book 1”, stock: 10 },
{ ID: 2, title: “Test Book 2”, stock: 5 },
];

async findBookById(ID: number): Promise<Book | undefined> {
return this.books.find((book) => book.ID === ID);
}

async updateBookStock(ID: number, newStock: number): Promise<void> {
const book = this.books.find((book) => book.ID === ID);
if (book) {
book.stock = newStock;
}
}
}

export { MockBookRepository };

Finally, write the unit tests. Note that by using testContainer, you do not depend on BookRepository defined in the srv directory but use MockBookRepository in the test directory.

// test/submitOrderHandler.test.ts
import { submitOrderHandler } from “../srv/submitOrderHandler”;
import { IBookRepository } from “../srv/iBookRepository”;
import { testContainer } from “./inversify.test.config”;
import { TYPES } from “../srv/types”;
import { Book } from “../srv/book”;

describe(“submitOrderHandler”, () => {
let mockBookRepository: IBookRepository;
// To keep the code simple for this hands-on, you use the any type without defining the request type.
let handler: (req: any) => Promise<Book | Error>;

beforeEach(() => {
mockBookRepository = testContainer.get<IBookRepository>(
TYPES.IBookRepository
);
handler = submitOrderHandler(mockBookRepository);
});

it(“should return the book and update stock when order is successful”, async () => {
const req = {
data: { book: 1, quantity: 2 },
error: jest.fn(),
};

const result = await handler(req);

expect(result).toEqual({ ID: 1, title: “Test Book 1”, stock: 8 });
expect(mockBookRepository.findBookById(1)).resolves.toEqual({
ID: 1,
title: “Test Book 1”,
stock: 8,
});
expect(req.error).not.toHaveBeenCalled();
});

it(“should return 404 error if book does not exist”, async () => {
const req = {
data: { book: 999, quantity: 2 },
error: jest.fn(),
};

await handler(req);

expect(req.error).toHaveBeenCalledWith(404, “Book #999 doesn’t exist”);
});

it(“should return 400 error if quantity is less than 1”, async () => {
const req = {
data: { book: 1, quantity: 0 },
error: jest.fn(),
};

await handler(req);

expect(req.error).toHaveBeenCalledWith(400, “quantity has to be 1 or more”);
});

it(“should return 409 error if quantity exceeds stock”, async () => {
const req = {
data: { book: 2, quantity: 10 },
error: jest.fn(),
};

await handler(req);

expect(req.error).toHaveBeenCalledWith(409, “10 exceeds stock for book #2”);
});
});

Finally, run the jest command in the terminal, and if the tests pass, the hands-on is complete.

npx jest

Summary

In this hands-on tutorial, you built a project using DI/DI containers on SAP CAP with TypeScript. As a result, the repository layer and handler layer became loosely coupled, improving testability.

As the next step, try adding entities like Authors to the bookshop, extending to DDD or Clean Architecture, or adding custom logic.

Reference Links

CAP – Getting StartedCAP – Using TypeScriptGitHub – cloud-sap-samples > bookshop 

​ IntroductionIn the theory part, the basic concepts of Dependency Injection (DI) and DI containers are covered. Now, let’s put that knowledge into practice. This guide assumes you are familiar with CAP, TypeScript, and using VSCode.Project Folder StructureTo make this hands-on tutorial accessible and easy to follow for many CAP users, the following features have been incorporated:Created a project based on the official CAP sample, bookshop.Simplified the directory structure by not creating subdirectories within the srv and test directories.Focused on DI/DI containers, omitting other design patterns and unnecessary layers.Used any type in some places to reduce code volume (not recommended for actual development).Used CSV file data and mock data instead of an external database.Registered only the repository layer in the DI container.Let’s get started!Creating a CAP ProjectFirst, follow the CAP Getting Started guide to create a project.npm add -g @sap/cds-dkOnce you have installed the cds toolkit, run the following commands:# Check if cds is installed correctly
cds

# Create a cds project
cds init bookshop-di

# Open the bookshop directory in VSCode
code bookshop-diFor Mac users, refer to the following to enable the code command:Assumes you activated the code command on macOS as documentedInstalling Required PackagesInstall the packages needed for the project. You will use the InversifyJS library for dependency injection (DI).# TypeScript
npm install -g typescript ts-node

# InversifyJS
npm install inversify reflect-metadata

# Jest
npm install –save-dev jest ts-jest @types/jestWith the necessary packages installed, let’s look at the code in each directory.Root DirectoryTo use InversifyJS, create a tsconfig.json file with experimentalDecorators and emitDecoratorMetadata set to true.// tsconfig.json
{
“compilerOptions”: {
“target”: “es6”,
“module”: “commonjs”,
“strict”: true,
“esModuleInterop”: true,
“skipLibCheck”: false,
“forceConsistentCasingInFileNames”: true,
“experimentalDecorators”: true,
“emitDecoratorMetadata”: true
},
“include”: [“srv/**/*”, “test/**/*”],
“exclude”: [“node_modules”]
}This is the configuration file for Jest, which you will use for unit testing.// jest.config.js
module.exports = {
preset: “ts-jest”,
testEnvironment: “node”,
};db DirectoryYou will use simplified entities and data based on the bookshop example.Here is the entity definition in the CDS file:// db/schema.cds
entity Books {
key ID : Integer;
title : localized String(111) @mandatory;
stock : Integer;
}Here is the data in the CSV file:# db/data/Books.csv
ID,title,stock
201,Wuthering Heights,101,
207,Jane Eyre,107srv DirectoryHere is the type definition for Book:// srv/book.ts
export interface Book {
ID: number;
title: string;
stock: number;
}Here is the CDS file:// srv/catalogService.cds
using {Books as DbBooks} from ‘../db/schema’;

service CatalogService {
entity Books as projection on DbBooks;

action submitOrder(book : Integer, quantity : Integer) returns Books;
}Next, you will look at the repository, handler, service, and DI container in relation to DI.First, let’s explain IBookRepository. You define findBookById and updateBookStock to be used in the business logic of submitOrderHandler. In the business logic, findBookById retrieves the target book, checks if there is enough stock to fulfill the order, and if so, updates the stock using updateBookStock.// srv/iBookRepository.ts
import { Book } from “./book”;

export interface IBookRepository {
findBookById(ID: number): Promise<Book | undefined>;
updateBookStock(ID: number, newStock: number): Promise<void>;
}Next is the concrete class, where you implement findBookById and updateBookStock. Here, you use the injectable decorator provided by the Inversify library, which allows the DI container to receive class-emitted metadata.// srv/bookRepository.ts
import { IBookRepository } from “./iBookRepository”;
import { Book } from “./book”;
import { injectable } from “inversify”;

@injectable()
class BookRepository implements IBookRepository {
async findBookById(ID: number): Promise<Book | undefined> {
return await SELECT.from(“Books”, ID, (b: Book) => ({
ID: b.ID,
stock: b.stock,
title: b.title,
}));
}

async updateBookStock(ID: number, newStock: number): Promise<void> {
await UPDATE(“Books”, ID).with({ stock: newStock });
}
}

export { BookRepository };Next, let’s explain submitOrderHandler.The key point here is that you do not instantiate BookRepository within this function but instead depend on the interface, IBookRepository using an argument. This allows submitOrderHandler to use any repository that implements IBookRepository, enabling us to use BookRepository in the local environment and MockBookRepository during unit testing. This loose coupling improves testability.Note: Switching between development and production data sources can be done using CAP’s features in package.json without using a DI container.// srv/submitOrderHandler.ts
import { Request } from “@sap/cds”;
import { IBookRepository } from “./iBookRepository”;

export const submitOrderHandler =
(bookRepository: IBookRepository) =>
// To keep the code simple for this hands-on, you use the cds Request type without defining the request type.
async (req: Request) => {
const { book: ID, quantity } = req.data;

const book = await bookRepository.findBookById(ID);
if (!book) {
return req.error(404, `Book #${ID} doesn’t exist`);
}

if (quantity < 1) {
return req.error(400, `quantity has to be 1 or more`);
}
if (quantity > book.stock) {
return req.error(409, `${quantity} exceeds stock for book #${ID}`);
}
// To keep the code simple for this hands-on, you omit the setter.
book.stock -= quantity;
await bookRepository.updateBookStock(ID, book.stock);
return book;
};In CatalogService, you define bookRepository and pass it as an argument to submitOrderHandler. According to CAP specifications, catalogService.ts (and catalogService.cds) serves as a route layer that exposes APIs based on its information, so you do not need to inject this service class, nor do you need the injectable decorator.// srv/catalogService.ts
import { IBookRepository } from “./iBookRepository”;
import { container } from “./inversify.config”;
import { submitOrderHandler } from “./submitOrderHandler”;
import { TYPES } from “./types”;
import cds from “@sap/cds”;

class CatalogService extends cds.ApplicationService {
async init() {
const bookRepository = container.get<IBookRepository>(
TYPES.IBookRepository
);

this.on(“submitOrder”, submitOrderHandler(bookRepository));
}
}

export default CatalogService;Finally, you configure the DI container. You might wonder how submitOrderHandler and CatalogService call BookRepository without having concrete classes. The DI container solves this issue, as its name suggests.Here are the actual codes for the two files. First, you define symbols (unique identifiers).// srv/types.ts
export const TYPES = {
IBookRepository: Symbol.for(“IBookRepository”),
};In the DI container, you bind the identifier TYPES.IBookRepository to the BookRepository class. This way, when the container requests TYPES.IBookRepository, it calls the concrete class BookRepository.// srv/inversify.config.ts
import { Container } from “inversify”;
import { IBookRepository } from “./iBookRepository”;
import { BookRepository } from “./bookRepository”;
import { TYPES } from “./types”;

export const container = new Container();
container.bind<IBookRepository>(TYPES.IBookRepository).to(BookRepository);Note: You can use the DI container without symbols. Specifically, you can replace TYPES.IBookRepository in the bind argument with the BookRepository class or the string ‘IBookRepository’. However, the former is not recommended because CatalogService calling the container would know the details of BookRepository, and the latter cannot guarantee uniqueness.’By using symbols, you can provide interface implementations in a way that the dependent class is no…With the API ready, start the local host and test it with the following command:cds-ts wWith the server running on port 4004, send a POST request defined in the http file. If “Send Request” does not appear in the http file, install the “Rest Client” extension.Note: Although you write the UPDATE process in BookRepository, the action defined in the CDS file is considered a POST process.// test/requests.http
###
POST http://localhost:4004/odata/v4/catalog/submitOrder
Content-Type: application/json

{ “book”:201, “quantity”:5 }If you get a 200 OK response, you have succeeded and can move on to the next part. If an error occurs, check if the file are named correctly and saved.test DirectoryNext is the unit test part, where you can enjoy the benefits of DI.First, define testContainer in the same format as srv/inversify.config.ts. Note that this time you bind to MockBookRepository.// test/inversify.test.config.ts
import { Container } from “inversify”;
import { IBookRepository } from “../srv/iBookRepository”;
import { TYPES } from “../srv/types”;
import { MockBookRepository } from “./mockBookRepository”;

export const testContainer = new Container();
testContainer
.bind<IBookRepository>(TYPES.IBookRepository)
.to(MockBookRepository);In MockBookRepository, you use mock data for books as shown below.// test/mockBookRepository.ts
import { injectable } from “inversify”;
import { IBookRepository } from “../srv/iBookRepository”;
import { Book } from “../srv/book”;

@injectable()
class MockBookRepository implements IBookRepository {
private books: Book[] = [
{ ID: 1, title: “Test Book 1”, stock: 10 },
{ ID: 2, title: “Test Book 2”, stock: 5 },
];

async findBookById(ID: number): Promise<Book | undefined> {
return this.books.find((book) => book.ID === ID);
}

async updateBookStock(ID: number, newStock: number): Promise<void> {
const book = this.books.find((book) => book.ID === ID);
if (book) {
book.stock = newStock;
}
}
}

export { MockBookRepository };Finally, write the unit tests. Note that by using testContainer, you do not depend on BookRepository defined in the srv directory but use MockBookRepository in the test directory.// test/submitOrderHandler.test.ts
import { submitOrderHandler } from “../srv/submitOrderHandler”;
import { IBookRepository } from “../srv/iBookRepository”;
import { testContainer } from “./inversify.test.config”;
import { TYPES } from “../srv/types”;
import { Book } from “../srv/book”;

describe(“submitOrderHandler”, () => {
let mockBookRepository: IBookRepository;
// To keep the code simple for this hands-on, you use the any type without defining the request type.
let handler: (req: any) => Promise<Book | Error>;

beforeEach(() => {
mockBookRepository = testContainer.get<IBookRepository>(
TYPES.IBookRepository
);
handler = submitOrderHandler(mockBookRepository);
});

it(“should return the book and update stock when order is successful”, async () => {
const req = {
data: { book: 1, quantity: 2 },
error: jest.fn(),
};

const result = await handler(req);

expect(result).toEqual({ ID: 1, title: “Test Book 1”, stock: 8 });
expect(mockBookRepository.findBookById(1)).resolves.toEqual({
ID: 1,
title: “Test Book 1”,
stock: 8,
});
expect(req.error).not.toHaveBeenCalled();
});

it(“should return 404 error if book does not exist”, async () => {
const req = {
data: { book: 999, quantity: 2 },
error: jest.fn(),
};

await handler(req);

expect(req.error).toHaveBeenCalledWith(404, “Book #999 doesn’t exist”);
});

it(“should return 400 error if quantity is less than 1”, async () => {
const req = {
data: { book: 1, quantity: 0 },
error: jest.fn(),
};

await handler(req);

expect(req.error).toHaveBeenCalledWith(400, “quantity has to be 1 or more”);
});

it(“should return 409 error if quantity exceeds stock”, async () => {
const req = {
data: { book: 2, quantity: 10 },
error: jest.fn(),
};

await handler(req);

expect(req.error).toHaveBeenCalledWith(409, “10 exceeds stock for book #2”);
});
});Finally, run the jest command in the terminal, and if the tests pass, the hands-on is complete.npx jestSummaryIn this hands-on tutorial, you built a project using DI/DI containers on SAP CAP with TypeScript. As a result, the repository layer and handler layer became loosely coupled, improving testability.As the next step, try adding entities like Authors to the bookshop, extending to DDD or Clean Architecture, or adding custom logic.Reference LinksCAP – Getting StartedCAP – Using TypeScriptGitHub – cloud-sap-samples > bookshop   Read More Technology Blogs by SAP articles 

#SAP

#SAPTechnologyblog

You May Also Like

More From Author