DJ Adams

Modules, modularity & reuse in CDS models - part 2 - creating a simple reuse package

Following on from part 1 in this series, I take a step back and look at the fundamentals of creating and working with a module, locally in this part, using the NPM workspace concept.

(Get to all the parts in this series via the series post.)

In wrapping up the end of part 1 we looked forward to creating the simplest kind of reuse module, not only as an important fundamental building block but as a base for further understanding and exploration (especially when we start looking closer at capire/common).

What we'll do in this post is:

Following best practice BES002 Think local-first we'll create and build out that NPM package locally, for the ultimate in development iteration and fast inner loops.

Creating a simple CAP Node.js project

For simplicity's sake, and to highlight the even more basic alternative approach (to using cds init) to create a new CAP Node.js project, we'll use the NPM command line tool npm as demonstrated by Daniel Schlachter in our Expert Session: Getting started with CAP Node.js - 2025/2026 edition!. After all, this post is ultimately all about Node.js packages.

Daniel uses npm init -y to start a new CAP Node.js project from
scratch

Here goes:

mkdir myproj \
  && cd $_ \
  && npm init -y \
  && npm add @sap/cds express \
  && npm add --save-dev @cap-js/sqlite

This creates everything we need for starting to develop a CAP Node.js project. Firing things up with cds watch gives us:

cds serve all --with-mocks --in-memory?
( live reload enabled for browsers )
        ___________________________

    No models found in db/,srv/,app/,app/*,schema,services.
    Waiting for some to arrive...

All subsequent command line invocations in this post are from the same location as this, i.e. at the project root, in myproj/.

Adding a simple CDS model

To keep things as simple as possible and not distract us from the goal, let's define the CDS model in services.cds as something minimal:

context schema {
  entity E {
    key ID : Integer;
        e  : String;
  }
}

service S {
  entity E as projection on schema.E;
}

For those wondering about the starkness, here are a few notes:

With the definitions in services.cds, we're up and running with a full service (some log lines omitted for brevity):

[cds] - loaded model from 2 file(s):

  services.cds
  node_modules/@sap/cds/srv/outbox.cds

[cds] - serving S {
  at: [ '/odata/v4/s' ],
  decl: 'services.cds:8',
  impl: 'node_modules/@sap/cds/srv/app-service.js'
}
[cds] - server listening on { url: 'http://localhost:4004' }

Now let's start to extend that model a little.

Extending with a separate file in the project

For the sake of illustration, let's say we have a custom type that we want to use in this model. We can take the smallest first step in the direction of a couple of best practices:

by factoring that type out into a separate file, and then importing it into our main model file services.cds with the using directive.

A simple type T

In a new file base.cds, also at the project root alongside services.cds, let's define the type:

type T : Boolean;

And in services.cds let's use that to redefine the element e:

using T from './base';

context schema {
  entity E {
    key ID : Integer;
        e  : T;
  }
}

service S {
  entity E as projection on schema.E;
}

So far so good.

Namespaces are a good idea

But that simple name T makes me a little nervous in that it's unscoped, i.e. at the equivalent of the global level, and this sort of practice is asking for trouble down the line with name clashes. So let's put this definition for T into a scope with the namespace directive; that way, we can more readily reuse and share it.

In base.cds:

namespace qmacro.common;

type T : Boolean;

In services.cds:

using qmacro.common.T from './base';

context schema {
  entity E {
    key ID : Integer;
        e  : T;
  }
}

service S {
  entity E as projection on schema.E;
}

That's better!

Everything works well, and I can add to the base.cds file and use the definitions therein as I build out the rest of the CDS model for the current project.

But one thing I cannot do is easily reuse those definitions in other projects.

Extending via an NPM package

To remedy this, I can transfer what I have in base.cds to a new NPM package, and use that. That way, I not only get to reuse the definitions in the current project but I can also:

Just like capire/common, in fact! But much, much simpler2.

Creating the package locally

Best practice BES002 Think local-first exhorts us to take a local-first approach to development. That goes not only for CAP components, but in general. And with NPM's workspaces concept, we can follow this best practice in building out our reuse package too.

See the Local development, submodules and workspaces section of the notes accompanying part 8 of The Art and Science of CAP for more on this.

Deciding on a scope and a name

Like the namespace we added to the definition in base.cds earlier, it's also a good idea to think about a scope for the package (in the same way that @sap, @cap-js and @capire exist). It would make sense to eventually publish it, and GitHub Packages' NPM registry seems a good bet.

So let's go for qmacro as the scope, and common as the package name3.

Setting up and examining the workspace constructs

Previously, in the CAP Node.js Plugins mini series, we set up a workspace in the Creating our own plugin package section of part 1. We'll take a similar approach now, taking care to specify our chosen scope this time:

npm init \
  --yes \
  --workspace packages/@qmacro/common \
  --scope @qmacro

Running this command does quite a few things. Let's look at (a cut down version of) the project's structure as a result of this:

./
├── node_modules/
│   ├── @cap-js/
│   │   └── ...
│   ├── @qmacro/
│   │   └── common -> ../../packages/@qmacro/common/ --+
│   ├── @sap/                                          |
│   │       └── ...                                    |
├── package-lock.json                                  |
├── package.json                                       |
├── packages/      <-----------------------------------+
│   └── @qmacro/
│       └── common/
│           └── package.json
├── base.cds
└── services.cds

What can we see? Well:

Additionally:

And if we look inside the project's package.json file, we'll see:

Adding the package as a project dependency

There's one more thing we should do now, but let's defer it to the end of this post, which will allow me to illustrate some local behaviours.

Transferring the type definition

Now that we have the NPM package, let's move the contents of base.cds to it, and also make a corresponding modification to the reference to it.

Moving the definition

The simplest first step is to move base.cds from where it is in the project root to the root of the @qmacro/common package, i.e. to packages/@qmacro/common/:

mv base.cds packages/@qmacro/common/

At this point, the structure for our project and our new local package looks like this (the node_modules/ directory content has been omitted here for brevity):

./
├── package-lock.json
├── package.json
├── packages/
│   └── @qmacro/
│       └── common/
│           ├── base.cds
│           └── package.json
└── services.cds

Changing the using directive

Next up we should modify the reference in services.cds. Instead of loading qmacro.common.T from ./base, i.e. the base.cds file that was in the project root, we'll want to load it from the @qmacro/common package. This is what we need to change services.cds to (just the using line has changed here):

using qmacro.common.T from '@qmacro/common';

context schema {
  entity E {
    key ID : Integer;
        e  : T;
  }
}

service S {
  entity E as projection on schema.E;
}

Understanding model resolution

At this point, if you're using a decent editor with LSP support connected to an instance of the CDS language server4, you'll likely see a diagnostics error:

Can't find package module '@qmacro/common' [file-unknown-package]

You can also elicit this error report by simply attempting to compile the contents of services.cds to CSN, like this, for example:

cds compile services.cds

This error occurs not because the local setup of the NPM package is somehow not right. It's because of the way model resolution works:

Resolving module references: Names starting with neither . nor / such as @sap/cds/common are fetched for in node_modules folders:

  • Files having .cds, .csn, or .json as suffixes, appended in order
  • Folders, from either the file set in cds.main in the folder's package.json or index.<cds|csn|json> file.

In other words, we either need to use an "index" file, or add configuration to the NPM package's package.json file to point to the actual file in there, i.e. base.cds; that cds.main configuration in package.json would look like this:

{
  "name": "@qmacro/common",
  "version": "1.0.0",
  "main": "index.js",
  "devDependencies": {},
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "cds": {
    "main": "base"
  }
}

With this in place, the reference in

using qmacro.common.T from '@qmacro/common';

works fine and the error goes away.

However, while it works, adding explicit configuration like this goes against axiom AX003 Convention over configuration. So let's not do that.

Moreover, it's actually good practice to use an "index" file (in the form of index.cds) as the entry point for reuse packages such as this one that we're creating - see BES007 Provide public entry points to reuse packages.

Creating the index entry point

So let's avoid any explicit cds.main configuration, and create an index.cds file in the package root. The simplest thing would be to rename base.cds, so that's what we'll do:

mv packages/@qmacro/common/base.cds packages/@qmacro/common/index.cds

Nice!

Adding the package as a dependency

There's one more thing we need to do, something which we deliberately deferred until now, which is to add the @qmacro/common reuse package as a project dependency.

Yes, everything works as we expect right now, but that's down to the NPM workspace concept. We have our project, and the workspace-hosted reuse package, which is accessible from the project. But what happens when we deploy that project elsewhere? The workspace-based development is just that - for development. So we need to ensure that our reuse package is brought in when setting up the project elsewhere.

Invoking:

npm add @qmacro/common

is all we need to do.

This adds a reference to the package in the project's dependencies property:

{
  "name": "myproj",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "description": "",
  "dependencies": {
    "@qmacro/common": "^1.0.0",
    "@sap/cds": "^9.6.1",
    "express": "^4.22.1"
  },
  "devDependencies": {
    "@cap-js/sqlite": "^2.1.2"
  },
  "workspaces": [
    "packages/@qmacro/common"
  ]
}

Wrapping up

At this point, we're all set, and we can continue to build out our project, and our reuse package, locally.

In the next part we'll move beyond local development for the reuse package, and make it available publicly in the NPM registry within GitHub Packages.

Thanks for reading!

Footnotes

  1. schema.cds is also one of the CDS roots

  2. And passive, rather than active - more on that another time.

  3. In fact, for a package to be hosted in the NPM registry within GitHub Packages, the scope inevitably is required and comes from the corresponding user or org name on GitHub. My user name on GitHub is qmacro. See Working with the npm registry for more details on this relationship.

  4. If you're using the SAP Business Application Studio, or VS Code with the SAP CDS Language Support extension, you'll have this already; if you want to set up Neovim to use the language server, see A modern and clean Neovim setup for CAP Node.js - configuration and diagnostics.