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
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)
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 include export default defineDmnoService({ //...
and the config must be set as isRoot: true
. Here is an example:
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 extremely similar - we just 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)
Also note that settings
values are inherited from parents unless set explicitly.
An example service config:
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 explicitparent
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:
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 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:
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.
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
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.
Note that many vendor-specific resuable data types from plugins will already be marked as 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 very rich 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!
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:
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.
An quick example to illustrate using our built-in switchBy
resolver which selects a value 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.