Dependency graph
Modules rarely stand alone. A workload needs an image somebody built and a pull secret somebody emitted. konfig.ts records those edges in the type system: a module declares what it needs and what it provides, and fromModules checks that every need has a provider. A missing edge is a compile error, not a secret "ghcr-pull" not found event in the cluster.
Needs and provides
A need is identified by a kind (Secret, Image, Namespace, and so on) and a literal name. A provide is the same identifier seen from the other side; the direction comes from which side of an Effect layer it sits on. For each kind there is a constructor you yield* inside a module’s build to consume, and for most kinds a provide* helper that returns a layer:
| Kind | Consume | Provide | What the consumer receives |
|---|---|---|---|
| Secret | Dep.Secret(name) | Dep.provideSecret(name) | a branded secret reference |
| SecretValues | Dep.SecretValues(name) | the values layer from Secret.bind / Environment.bind | the decrypted values, one Redacted<string> per key |
| ConfigMap | Dep.ConfigMap(name) | Dep.provideConfigMap(name) | a branded ConfigMap reference |
| Namespace | Dep.Namespace(name) | Dep.provideNamespace(name) | the name |
| ServiceAccount | Dep.ServiceAccount(name) | Dep.provideServiceAccount(name) | a branded ServiceAccount reference |
| Pvc | Dep.Pvc(name) | Dep.providePvc(name) | a branded PVC reference |
| Image | Dep.Image(name) | Dep.provideImage({ app, registry, tag }) | the full registry/app:tag image string |
| Application | Dep.Application(name) | Dep.provideApplication(name) | the name |
| App | Dep.App(name) | emitted by every Application.define | the built Argo CD Application |
Every module built with the Argo CD target automatically provides App, Application, and Namespace for its own name and namespace, and those same needs are removed from its input. That is why a module can yield* Dep.Namespace("app") for a namespace it creates itself without declaring anything.
Providing
A provider attaches a layer through the wrapper’s provides field. image-pulls.ts emits the GHCR pull credential and provides the Secret named ghcr-pull:
export const defineImagePulls = Application.module({
namespace: "app",
annotations: Sync.wave(-1),
provides: Dep.provideSecret("ghcr-pull"),
build: (ctx, opts: ImagePullsOpts) => {
const bound = Secret.bind({
secret: ghcrPull,
backend: Sops.passthrough({
file: `${opts.sopsBase}/SopsSecret-ghcr-pull.yaml`
})
})
return bound.manifest === undefined ? [] : [bound.manifest]
}
})What the provider hands out is a branded reference to the secret, not the manifest itself. Nothing checks that the module also emitted a Secret named ghcr-pull; the provide is a promise you make. Keep provider and manifest in the same module so they cannot drift.
Binding a secret also produces the layers that satisfy the graph. Secret.bind returns a declared secret whose refLayer provides the Secret need for that name; if you passed a source, it also carries a values layer for the decrypted values. Environment.bind does the same for every member (under members) and merges the value layers into one valuesLayer.
You only need these layers when you build a secret inside a build function and want the same module to provide it. Because provides on Module.fixedNs / Module.dynamicNs is declared at wrapper level, it cannot see values computed inside build; in that case use Application.define directly and pass the layers there.
Consuming
A consumer returns an Effect from build and yields what it needs:
build: ({ name, namespace }, opts: ApiOpts) =>
Effect.gen(function*() {
const ghcrRef = yield* Dep.Secret("ghcr-pull")
const apiImage = yield* Dep.Image("api")Each yield* adds one need to the Effect’s requirements. The wrapper carries those requirements into the module handle as its declared input, minus whatever the module provides itself. Nothing is resolved at this point; the handle only records what it wants.
Resolution at fromModules
AppOfApps.fromModules({ target, defaults, modules, provides? }) walks the module handles in order, letting each module’s provides satisfy the needs of the modules after it. The types follow the same walk: each module’s needs are checked against everything earlier modules provide, plus whatever the optional group-level provides: layer supplies.
fromModules accepts the module list only when nothing is left unresolved, and returns a sealed, directly renderable Effect. Otherwise the call site gains a _konfig_unsatisfied property with a hint such as Missing provider for Secret "ghcr-pull". Add a module that provides it to AppOfApps.fromModules({ modules }), pass it via provides:, or check that providers come before consumers in the list. The default export of an env file must be that program, so an unresolved graph never reaches konfig build.
const api = defineApi({
name: "api",
source: src("api"),
replicas: 1,
sopsBase: "infra/secrets"
})
const worker = defineWorker({
name: "worker",
source: src("worker"),
replicas: 1,
sopsBase: "infra/secrets"
})
export default AppOfApps.fromModules({error TS2345: Missing provider for Image "api", Image "worker" and Secret "ghcr-pull".
Add a module that provides them to AppOfApps.fromModules({ modules }), or check that providers come before consumers in the list.
target: { repoURL: cluster.repositoryUrl, branch, rootPath },
defaults: {},
modules: [api, worker]
})Ordering
The fold is a left fold, so providers must come before consumers in modules. Listing api before apiBuild leaves the Image need for api unsatisfied even though a provider exists later in the array. infra/envs/prod.ts lists the sops operator, image pulls, feature flags, postgres, the build anchors, and the redis cache before api and worker for this reason. Sync ordering inside the cluster is a separate concern, handled with Sync.wave annotations.
Duplicates
Two modules providing the same name would silently shadow each other, so fromModules folds the list a second time and rejects duplicates for App, Secret, SecretValues, ConfigMap, ServiceAccount, Pvc, and Image. A hit adds a _konfig_duplicate property with the message Duplicate <Kind> "<name>": two modules in AppOfApps.fromModules({ modules }) provide the same name; the later one silently shadows the earlier. Rename one of them. Namespace and Application are exempt: shared namespaces are normal, and Application is always emitted alongside App.
const apiV1 = Application.define({
name: "api",
namespace: "app",
source: src("api"),
build: () => []
})
const apiV2 = Application.define({
name: "api",
namespace: "app",
source: src("api"),
build: () => []
})
const collision = AppOfApps.fromModules({
target: { repoURL: cluster.repositoryUrl, branch: "main", rootPath: "./out" },
defaults: {},
modules: [apiV1, apiV2]error TS2345: Duplicate App "api": two modules in AppOfApps.fromModules({ modules }) provide the same name; the later one silently shadows the earlier.
Rename one of them. as const
})
export default collisionRuntime behaviour
Every check above is erased at compile time. At runtime the handles are merged into one Effect layer, each module’s Application is collected, and AppOfApps.make packages them with a name (default "apps"), the target, and the defaults. Without the compile-time check, a missing service would only show up as a runtime defect from Effect’s layer system; the type-level walk exists so it never gets that far.
Reference
The names behind the checks above, for when you read the types or a compiler message:
| Name | Role |
|---|---|
Dep.Need<K, N> / Dep.Provide<K, N> | The type-only marker for a need or provide of kind K and literal name N. Both are the same type. |
Compose.ResidualIn<Ms> | The type-level walk that filters each module’s input against every earlier module’s output. |
Compose.NoDuplicateProvides | The second fold that collects names provided twice. |
Compose.composeLayers | The runtime fold: reduces the handles with Layer.provideMerge into one layer. |
Layer.provideMerge | The Effect combinator both folds are built on. |
Next: Branded refs.