A service-dependencies entry says which service another one needs. Until now
it did not say why, so every edge constrained every operation: a generated
client that only matters at build time forced the producer to be started, and a
consumed endpoint that only matters at run time forced the producer to be built
first.
Declaring a kind says which execution stage the edge constrains.
| Kind | Meaning | Stages it constrains | What the consumer waits for |
|---|---|---|---|
| (absent) | Legacy edge | build, run | consumed endpoint health |
build |
Build/codegen input — a generated client, a shared artifact | build | nothing |
schema |
The producer's declared API contract is read at build time | build | nothing |
runtime |
A consumed endpoint | run | consumed endpoint health |
completion |
One-shot work (a migration, a seed job) must finish | run | completed run |
external |
A capability provided outside the workspace | none | nothing (unless composition ordering binds the pair — see below) |
A stage is an elementary graph that can be topologically sorted: build or
run. A phase is an operation a caller asks for, and it decomposes into
ordered stages:
| Phase | Stages, in order |
|---|---|
build |
build |
run |
run |
test |
build, then run |
deploy |
build, then run |
So a build dependency does constrain test and deploy — you cannot test
or deploy a service without building it first. What it never does is order a
run.
The stages must stay separate graphs. Merging them would union a build-time edge with a runtime edge pointing the other way and report a cycle — exactly the misclassification kinds exist to remove.
completion and runtime are not interchangeable. Polling a migration job for
endpoint health never succeeds; treating a long-running service as "done" starts
its consumers early. A completion dependency therefore cannot declare
endpoints — that combination is rejected at load.
An external dependency is declared so configuration and documentation can name
the capability. Codefly never builds, starts or orders it.
external describes the edge, not the producer. When the producer is also a
real service of the workspace — it is loaded, or another service consumes it
under any other kind — it stays a service in the graph and runs whenever that
other consumer runs; the external declaration only means the declaring service
does not pull it in or wait for it. The order services are listed in never
changes this.
A composition root can bind the same two services itself, by writing an
${endpoint:<module>/<service>/<endpoint>} reference into a workspace
configuration group the consumer declares (workspace-configuration-dependencies).
That reference is not documentation: it resolves to the producer's address and is
injected into the consumer's environment, so the producer has to be running for
the value to mean anything. When a run is built with those references
(architecture.WithConfigurationReferences), such an edge is a runtime edge: the
producer is included in the run, started before the consumer, and waited for on
the endpoint the reference names — the consumer's own external declaration of
the same pair is superseded, and the run logs that it was.
The declaration still decides everything the root did not bind: a producer no
group of the consumer references is pulled in and waited for by nobody, exactly
as above. Declaring external a producer the composition root binds anyway is
worth rewriting as kind: runtime, which says the same thing in the consumer's
own manifest — the override exists so a composition that did not is still
correct, not as a second way to spell it.
An explicit runtime prerequisite can also supersede an external graph edge.
architecture.WithRuntimePrerequisites takes consumer-to-prerequisite
module/service identities when constructing NewServiceDependencies. It adds
ordering through the existing runtime graph, without modifying service manifests
or granting endpoint access, configuration or readiness declarations. The caller
still owns the actual readiness or admission condition before starting a consumer.
No configuration group or endpoint reference is needed for a pure ordering rule.
Both identities must name loaded workspace services; a dangling dependency node is not a loaded service. A real excluded or skipped consumer imposes no outgoing requirement. An active consumer naming a missing or excluded prerequisite is refused. A skipped prerequisite remains selectable by its consumers. Mandatory prerequisites are applied before configuration-reference edges, and cycles are refused per stage rather than silently dropping a mandatory edge. They affect run ordering and transitive closure, not build ordering. Use stage views as for other kinded edges; a union of build/run graphs can contain a legitimate cycle.
The option snapshots its input; repeated uses replace the previous map. Derived
graph views hold independent copies. Nil or empty input is a no-op. The older
manifest-only SelectClosure walk refuses a nonempty runtime prerequisite option
instead of silently returning a closure that omits it.
The two-stage relationship below is legitimate: the API talks to the database at run time, and the database bootstrap reads the API contract to generate its migrations at build time. The union of both edges is a cycle; neither stage's graph is.
# services/api/service.codefly.yaml
name: api
service-dependencies:
- name: database
kind: runtime
endpoints:
- name: postgres
endpoints:
- name: grpc
api: grpc
visibility: public
exposure: none# services/database/service.codefly.yaml
name: database
service-dependencies:
- name: api
kind: schema
endpoints:
- name: grpc
endpoints:
- name: postgres
api: tcp
visibility: public
exposure: noneA cycle within one stage — two services each declaring a runtime
dependency on the other — still fails, and the error names the stage and the
path (run dependencies have a cycle: a -> b -> a).
# services/worker/service.codefly.yaml
name: worker
service-dependencies:
- name: migration # must have COMPLETED before the worker starts
kind: completion
- name: api # only needed to generate a client
kind: build
endpoints:
- name: grpc
- name: stripe # provided outside the workspace
module: vendor
kind: externalStage participation is not consumption. The kind states both, for every kind
(DependencyKind.ConsumesEndpoints, DependencyKind.ReachesEndpoints):
| Kind | Stages | Consumes the producer's endpoints | Reaches them at run time |
|---|---|---|---|
| (untyped) | build, run | yes (calls them) | yes |
build |
build | yes (reads the contract) | no |
schema |
build | yes (reads the contract) | no |
runtime |
run | yes (calls them) | yes |
completion |
run | no — it waits for the producer to finish | no |
external |
none | no — the producer is outside the workspace | no |
An edge that consumes nothing selects no endpoint, whatever it names or omits:
an omitted endpoint list on a completion edge is not "all", so the consumer
is handed no address of the job it waits on, and the derived allow-list
(network-model.md)
records no ask from it. Only a reaching edge derives an entry: a build edge
reads the contract and never calls, so it grants nothing either.
- Visibility is enforced on every consumed interface regardless of kind: a
buildedge crosses the same export boundary as aruntimeone. - An endpoint reference that names an endpoint the producer does not export is rejected for every kind.
- An entry with no
kindbehaves exactly as it did before kinds existed, so an existing workspace keeps its order and its validation errors. In particular an endpointless producer under an undeclared kind stays valid: that is how workspaces expressed one-shot work beforecompletionexisted.
| Existing declaration | Keep as is when | Declare instead |
|---|---|---|
| Dependency whose endpoints the consumer calls at run time | always safe | kind: runtime |
| Dependency used only to generate a client or read a schema | it also needs to be running | kind: build / kind: schema |
| Dependency on a job that must finish first | never — legacy waits for endpoint health | kind: completion |
kind: runtime onto a producer with no endpoints |
never — it waits forever; this is rejected | kind: completion |
| Dependency on something codefly does not manage | it is a real workspace service | kind: external |
Migration is per-edge and additive: an undeclared kind stays legacy, so a workspace can be migrated one dependency at a time.
deps, _ := architecture.NewServiceDependencies(ctx, workspace)
// Fails when any stage's graph deadlocks, naming the stage and the cycle.
err := deps.VerifyAcyclic(ctx)
// What a caller wants: the ordered stages of an operation. `test` and `deploy`
// return a build order followed by a run order.
stages, _ := deps.OrderFor(ctx, resources.PhaseTest, unique)
for _, stage := range stages {
// stage.Stage is resources.StageBuild, then resources.StageRun
// stage.Services is that stage's order
}
// A single stage's graph, when that is what you need.
build, _ := deps.ForStage(resources.StageBuild)
buildOrder, _ := build.OrderTo(ctx, unique)An unknown stage or phase name is an error, never an empty result: a graph with no edges is indistinguishable from "nothing depends on anything", so a typo would silently remove all ordering.
Restricting a graph — by stage or to a single service — keeps the edge kinds and drops the service lookup entries for nodes it removed, so a service that is not in the restricted graph cannot be resolved through it.
A configuration reference retains its producer in the run even when the reverse startup dependency already exists: a service whose configuration names a callback owner that itself depends on the service gets that owner selected, started and ordered as a member of the same single-root run. Selection computes the fixed point of referenced producers and their declared dependencies; it does not add extra roots or ignore excluded services. This participation applies to runtime selection, not build-only selection, and survives graph restriction.
Participation is membership, not a resolved address. Startup ordering remains
acyclic, so the consumer may initialize before the callback owner has published
where it listens, and whether the consumer's reference resolves at that moment
is the orchestrator's to answer. It can when the owner's address is a function
of its identity — deterministic named ports, a deployed in-cluster address — and
it cannot when addresses are allocated per run at initialization (the CLI's
--temporary-ports). In that case the orchestrator owns the verdict: drop the
value for this consumer as it did while the owner was outside the run, or
allocate addresses before any service initializes. It must not ask the consumer
to order or declare the owner, which is the cycle this edge was dropped to
avoid. Core states who takes part; it does not promise the consumer a mapping.
A reference that would close a startup cycle adds no pre-start health wait for the service waiting on that consumer. The callback owner still starts in the selected flow and undergoes the ordinary service readiness checks. Configuration references do not make a declared startup cycle valid or prove either application's request-time readiness.