Skip to content
Docs menu / Dependency graph

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:

KindConsumeProvideWhat the consumer receives
SecretDep.Secret(name)Dep.provideSecret(name)a branded secret reference
SecretValuesDep.SecretValues(name)the values layer from Secret.bind / Environment.bindthe decrypted values, one Redacted<string> per key
ConfigMapDep.ConfigMap(name)Dep.provideConfigMap(name)a branded ConfigMap reference
NamespaceDep.Namespace(name)Dep.provideNamespace(name)the name
ServiceAccountDep.ServiceAccount(name)Dep.provideServiceAccount(name)a branded ServiceAccount reference
PvcDep.Pvc(name)Dep.providePvc(name)a branded PVC reference
ImageDep.Image(name)Dep.provideImage({ app, registry, tag })the full registry/app:tag image string
ApplicationDep.Application(name)Dep.provideApplication(name)the name
AppDep.App(name)emitted by every Application.definethe 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:

examples/full-stack/infra/modules/image-pulls.ts
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:

examples/full-stack/infra/modules/api.ts
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.

examples/full-stack/infra/envs/broken.ts 1 error
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]
})
$ bun run check
infra/envs/broken.ts(30,3): error TS2345: Property '_konfig_unsatisfied' is missing … "Missing provider for Image \"api\"…" | "Missing provider for Image \"worker\"…" | "Missing provider for Secret \"ghcr-pull\"…"

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.

examples/full-stack/infra/envs/app-name-collision.ts 1 error
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 collision
$ bun run check
infra/envs/app-name-collision.ts(26,41): error TS2345: Property '_konfig_duplicate' is missing … "Duplicate App \"api\"…"

Runtime 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:

NameRole
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.NoDuplicateProvidesThe second fold that collects names provided twice.
Compose.composeLayersThe runtime fold: reduces the handles with Layer.provideMerge into one layer.
Layer.provideMergeThe Effect combinator both folds are built on.

Next: Branded refs.