Skip to content
DMNO
🚧 DMNO is still in beta! Use with caution!
✨ If you've tried DMNO or looked through the docs, let us know what you think!

Schema authoring guide

If you haven’t already, follow our Quickstart guide to get started with DMNO. Once you’re set up and ready to go, let’s dive in to writing a full schema for your project.

Project structure

Using DMNO, we define a “configuration schema” for your entire project. We break that configuration up to live alongside each of the services in your code, and provide ways for them to reference each other as needed. Each service config is defined in a config.mts TypeScript file that lives in a .dmno directory.

You’ll always have a .dmno/config.mts file at the root of your project, and if you are working in a monorepo, you’ll have another .dmno/config.mts in each of your “services” that require configuration. You may have some workspace packages that are purely shared libraries, and don’t have any configuration. These do not need a .dmno folder.

A typical DMNO project structure:

  • Directory/ (root of your project)
    • Directory.dmno
      • Directory.typegen/ (generated types)
        • …
      • .env.local (your local overrides and sensitive values)
      • config.mts (your config schema)
      • tsconfig.json (dmno specific tsconfig)
    • Directoryapi/
      • …
    • Directorysome_folder/
      • …
    • Directoryfrontend/
      • …
    • Directoryscripts/
      • …
    • …etc

Root service config

Each config.mts file will define settings for that service, including a full schema of all the config items used by the service. The root service config is a little special, in that it also can define some settings for the entire workspace - although in general most settings are inheritable from a chain of ancestors, not just the root.

But as the root service is an ancestor of all other services, it is where any workspace-wide settings and config items will live.

This file must have export default defineDmnoService({ //.... Here is an example:

.dmno/config.mts
import { DmnoBaseTypes, defineDmnoService, NodeEnvType } from 'dmno';
export default defineDmnoService({
// you must mark the root with `isRoot: true`
isRoot: true,
// service name (optional, but recommended)
name: 'root',
// workspace level settings, inherited by children (optional)
settings: { /* ... */ },
schema: {
// your root config contains shared items used by your other services
NODE_ENV: NodeEnvType,
DISCORD_JOIN_LINK: {
value: 'https://chat.dmno.dev',
description: 'link for our users to join us on discord (uses a redirect)',
},
CUSTOMER_SERVICE_EMAIL: {
extends: DmnoBaseTypes.email,
description: 'The email address for customer service',
// changes the value based on the environment
value: switchByNodeEnv({
_default: 'test@test.com',
staging: 'staging@test.com',
production: 'production@test.com',
}),
},
},
});

If you’re not in a monorepo, you can skip to Defining config items.

Other services

The rest of your services (if you are working in a monorepo) are very similar, however they call a different function (defineDmnoService()) and we gain a few new options:

  • parent - set the service’s parent (by name), otherwise will default to the root service
  • pick - pick specific config items from other services to use in this one (see sharing config for more info)

An example service config:

packages/api/.dmno/config.mts
import { defineDmnoService, DmnoBaseTypes } from 'dmno';
import { StripeDataTypes } from 'stripe-dmno-plugin'; // doesn't exist yet
export default defineDmnoService({
name: 'api',
parent: 'backend',
pick: [
'NODE_ENV',
'DISCORD_JOIN_LINK',
{ source: 'database', key: 'DB_URL' },
],
schema: {
// configuration defined only within this service
STRIPE_PUB_KEY: {
// uses a custom type provided by a plugin
extends: StripeDataTypes.StripePublishableKey,
description: 'The publishable Stripe API key for this project',
},
API_URL: {
extends: DmnoBaseTypes.url,
expose: true,
},
},
});

Service names

Every service must have a unique “service name” within your workspace. You can set it in the service’s config, although if you do not specify a name, we will use the name field from that service’s package.json file.

We recommend you set it to something short - for example api instead of @my-cool-org/api - because you may be typing it into CLI commands (e.g., pnpm exec dmno resolve -s api) and it will be visible in several places in terminal output.

You’ll also use it as a reference to the service when services need to reference each other in their configuration, like when picking config (see next section).

Sharing config between services

In any service config you can “pick” config items from other services to make them available within the service. The set of items you can pick from follow 2 rules:

  • you can pick any config item from an ancestor service
    remember everything is a direct child of the root unless an explicit parent is set
  • otherwise you can only pick config items that are marked with expose: true

Additionally you can transform keys and values before exposing them within the current service.

The pick syntax is very flexible and best illustrated with a few examples:

export default defineDmnoService({
name: 'api',
parent: 'backend-services',
pick: [
// you can specify the source service name and key(s) to pick
{
source: 'root',
key: 'SINGLE_KEY',
},
// if source is omitted, it will fallback to the workspace root
{ key: 'OTHER_KEY_FROM_ROOT' },
// shorthand to pick single key from root
'SHORTHAND_PICK_FROM_ROOT',
// you can pick multiple keys at once
{
source: 'other-service',
key: ['MULTIPLE', 'KEYS'],
},
// you can pick by filtering keys with a function
// (from all items if an ancestor or just exposed items if not)
{
source: 'backend-services',
key: (key) => key.startsWith('DB_'),
},
// keys can be transformed
// and you can use a static value if picking a single key
{
key: 'ORIGINAL_KEY',
renameKey: 'NEW_KEY_NAME',
},
// or use a function if picking multiple
{
key: ['KEY1', 'KEY2'],
renameKey: (k) => `PREFIX_${k}`,
},
// values can also be transformed with functions
{
source: 'backend-services',
key: 'GROUP1_THINGY',
transformValue: (v) => v + 1,
},
],
});

Defining config items

Your service’s config has a schema which is a key-value object that describes all of the configuration your service uses. Each item has a definition that describes what kind of data it is, how to validate it, how to handle it within your build, a rich description that feeds into your IDE tooling, and in some cases, what the value is or how to generate / fetch it. More on that later.

We’ll start with a simple example from our own monorepo, and then dig into what all the options are:

export default defineDmnoService({
schema: {
// ...
GITHUB_REPO_URL: {
extends: DmnoBaseTypes.url({ allowedDomains: ['github.com'] }),
description: 'Github link to the main DMNO monorepo',
required: true,
value: (ctx) => {
return `${DMNO_CONFIG.GITHUB_ORG_URL}/${DMNO_CONFIG.GITHUB_REPO_NAME}`;
},
},
},
});

Data types & extends

Each item is defined by extending some base type (whether explicitly or not) and adding additional overrides on top of it.

Most of the time, you’ll use existing data types, either from DmnoBaseTypes or from a published dmno plugin - some by dmno, some by others. These data types are factory functions, and accept settings that control reusable behavior like validation rules. You should almost always be able to accomplish what you need with existing types - but of course you can author your own resuable types as well.

These types are also used to generate TypeScript types for your config and give you type safety when using your config in your application code.

Again, the syntax for extends is rich, and best illustrated via some examples:

export default defineDmnoService({
schema: {
// common case where a datatype is called as a function w/ settings
EXTENDS_TYPE_INITIALIZED: {
extends: DmnoBaseTypes.number({ min: 0, max: 100 }),
},
// you can skip the function call if no settings are needed
EXTENDS_TYPE_UNINITIALIZED: {
extends: DmnoBaseTypes.number,
},
// string/named format works for a few of our basic types (with no settings)
EXTENDS_STRING: {
extends: 'number',
},
// passing nothing will try to infer the type from a static value
// or fallback to a string otherwise
DEFAULTS_TO_NUMBER: { value: 42 }, // infers number
DEFAULTS_TO_STRING: { value: 'cool' }, // infers string
FALLBACK_TO_STRING_NO_INFO: { }, // assumes string
FALLBACK_TO_STRING_UNABLE_TO_INFER: { // assumes string
value: somePlugin.item(),
},
// of course you can use your own custom types (or from plugins)
USE_CUSTOM_TYPE: {
extends: MyCustomPostgresConnectionUrlType,
// additional settings can be added/overridden as normal
required: true,
},
},
});

Validations & required config

Validating your config BEFORE build/run/deploy is a huge part of what makes DMNO so powerful.

You can mark items as required: true and they will be considered invalid if the value is empty when we load your config. Additional validations will be skipped if this is the case.

You can also attach custom validation functions, although most of your validation needs will likely be handled by reusable types.

export default defineDmnoService({
schema: {
VALIDATION_EXAMPLE: {
extends: DmnoBaseTypes.number({ min: 0, max: 100 }),
required: true,
validate: (val) => {
if (!isPrimeNumber(val)) {
throw new ValidationError('Number must be prime');
}
},
},
},
});

Secrets & security

Items can be marked as sensitive: true and they will be treated accordingly. That means:

  • We will mask their values when logging them to the console via the dmno CLI
  • They will NOT be exposed via DMNO_PUBLIC_CONFIG, only via DMNO_CONFIG
  • Depending on the integration, we will help make sure you don’t accidentally leak them
  • More amazing security-related features coming soon!

Note that many vendor-specific resuable data types from plugins will already be marked as sensitive!

export default defineDmnoService({
schema: {
MY_SECRET: {
sensitive: true,
},
ONE_PASS_TOKEN: {
// this data type is already marked as sensitive!
extends: OnePasswordTypes.serviceAccountToken,
},
},
});

Docs & IntelliSense

DMNO lets you attach additional information to items that serve as inline documentation about the item. This data is also used to generate very rich TypeScript JSDoc comments for your config - giving you and your team ✨ magical IDE superpowers.

export default defineDmnoService({
schema: {
INTELLISENSE_DEMO: {
required: true,
sensitive: true,
summary: 'Primary DB URL',
description: 'houses all of our users, products, and orders data',
// description of the type of the data rather than this instance of it
typeDescription: 'Postgres connection url',
externalDocs: {
description: 'explanation (from prisma docs)',
url: 'https://www.prisma.io/dataguide/postgresql/short-guides/connection-uris#a-quick-overview',
},
ui: {
// uses iconify names, see https://icones.js.org for options
icon: 'akar-icons:postgresql-fill',
color: '336791', // postgres brand color :)
},
},
},
});

An example of how the generated types show up with VSCode’s IntelliSense: Intellisense demo

Dynamic vs static

Items can use dynamic: true or false to override their behaviour as to whether they should be bundled into your code at build time versus always loaded at boot time. This is only relevant for some integrations/projects, and it’s a big topic. See our dynamic config guide for more details!

Expose

Items marked with expose: true will be available to be “picked” by other services in your workspace that are not children of the service. See “sharing config” section above.

Setting item values

While some tools may let you set only default values for config items, DMNO lets you set the value from within your schema for all situations.

This is possible because the dmno config loading process is broken up into 2 stages: first we load the schema, and then we “resolve” the values. While the resolution process does respect overrides passed in as env vars and from local override files, many of your values may be set from the schema directly.

We can use static values, inline functions, or a resolver - which is basically just a fancy function that will be called during the resolution process and passed some contextual data about the config item and the rest of the resolved config.

An example of setting the value to each of these cases in our schema:

export default defineDmnoService({
schema: {
// static value (for constants or defaults planned to be overridden)
STATIC_VAL: {
value: 'static',
},
// use an inline function which references other item values
INLINE_FN_VAL: {
value: (ctx) => `prefix_${DMNO_CONFIG.OTHER_ITEM}`,
},
// using an instance of a "resolver" from a plugin
RESOLVER_EXAMPLE: {
value: somePlugin.fetchSecretItemById('xyz'),
},
},
});

Internally, we even wrap the static values and inline functions into resolvers, so that we can always display some additional metadata about how the value is being set - even without actually resolving it. These resolvers can also return more resolvers, and have a notion of multiple branches built in, leading to some very powerful composition capabilities.

An quick example to illustrate using our built-in switchByNodeEnv resolver which selects a value based on the current value of NODE_ENV within your config.

export default defineDmnoService({
pick: ['NODE_ENV'],
schema: {
SOME_API_KEY: {
value: switchByNodeEnv({
// static values for pre-prod, so not a problem to include here
_default: 'dev123',
test: 'test123',
staging: 'staging123',
// sensitive key we don't want in our repo
production: prodOnePassSecrets.item(),
}),
},
},
});

You can author your own reusable resolvers - but you likely won’t need to for most use cases.