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 that lives in a config.mts
TypeScript file within a special .dmno
directory at the root of your project. In a monorepo project with multiple child packages/services, we keep the root config, but we also break things up into multiple .dmno/config.mts
files that live alongside each child and provide mechanisms for them to reference each other as needed.
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)
Directorysrc/
- …
Directoryscripts/
- …
- package.json
- …etc
Directory/ (root of your project)
Directory.dmno/
- …
Directorypackages
Directoryapi
Directory.dmno/
- …
- …
Directoryfrontend
Directory.dmno/
- …
- …
Directoryshared-lib
- files… (no .dmno folder if no env vars are needed)
DMNO config files
Your .dmno/config.mts
file will define settings for your project, including a full schema of all the config items used within your project. Your config file must include export default defineDmnoService({ //...
. Here is an example:
Multiple .dmno/config.mts
files in a monorepo
In a monorepo, aside from the root config file, each child service will have its own .dmno/config.mts
file, and a few more options/features become relevant:
- specifying a service
name
now becomes more important - we can specify a
parent
service - we can now share config items across services (see sharing config for more info)
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 when services need to point to each other in their configuration, like when using pick
and parent
as seen above.
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:
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 plugin - some by DMNO, some by others. These data types are factory functions, and accept settings that control reusable behavior like validation rules and docs info. You should almost always be able to accomplish what you need with existing types - but you can author your own reusable types as well.
These types are also used to generate TypeScript types for your config and give you type safety with docs when using your config in your application code.
The syntax for extends
is rich, and best illustrated via some examples:
Sharing config between services
In a monorepo project with multiple services, DMNO allows config to be shared and composed across multiple services. In any service schema
you can pick
config items from other services to make them available within the service.
The pick
function is a special kind of data type, and it copies all of the source item’s properties, not just the value. So you can think of the picked item as extending the data type of the original item. Use it like any other data type in an item’s extends
property. There are 2 optional arguments to help specify the original item to pick from, with defaults being to pick from the root service, and use the same key/path.
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. Note that if the config item has a static value set, for example value: 'some-val'
then we will infer that the item is required. In the rare case that you plan on sometimes overriding the value to undefined
, you can add required: false
. Note that this required
setting affects DMNO’s generated types as to whether the value might be undefined
.
You can also attach custom validation functions, although most of your validation needs will likely be handled by reusable base types.
Secrets & security
Items can be marked as sensitive: true
and they will be treated accordingly. That means:
- They will NOT be exposed via
DMNO_PUBLIC_CONFIG
, only viaDMNO_CONFIG
- We will redact their values when logging them to the console via the
dmno
CLI - If the
redactSensitiveLogs
service setting is enabled, we will patch globalconsole
methods redact the value from all logs - If the
interceptSensitiveLeakRequests
service setting is enabled, we will patch global http methods to intercept requests that send it to any domain not on theallowedDomains
list - Depending on the integration, we will help make sure you don’t accidentally leak them in bundled client-side javascript or server-rendered responses
To customize behavior, you can set sensitive
to an object rather than true
. Note that an empty object will still mark the item as being sensitive.
Redact modes
For config values that have a common prefix, showing the first 2 characters will not be very helpful for identification. We provide several different redactMode
settings to customize how the sensitive value is displayed when redacted. The following table shows the different modes supported:
value | description | example |
---|---|---|
show_first_2 ⭐ default | show the first 2 characters only | ab▒▒▒▒▒▒ |
show_last_2 | show the last 2 characters only | ▒▒▒▒▒▒yz |
show_first_last | show the first and last characters only | a▒▒▒▒▒▒z |
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 TypeScript JSDoc comments for your config - giving you and your team ✨ magical IDE superpowers.
An example of how the generated types show up with VSCode’s IntelliSense:
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.
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 process environment variables and other sources like .env 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:
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 will be resolved - even before attempting to perform the resolution. There is also a concept of branching to handle things like if-else and switch statements that point to more resolvers, leading to some very powerful composition capabilities.
A quick example to illustrate using our built-in switchBy
resolver which switches between several branches based on the current value of another within your config.
You can author your own reusable resolvers - but you likely won’t need to for most use cases.