Skip to content

Multi-Stack Projects

An estate outgrows one stack. chant’s answer is side-by-side projects: each stack is its own project, with its own chant.config.ts and its own flat src/. They sit next to each other and reference each other by name.

Nothing about a project changes because it has siblings. Each one is an ordinary single-stack project — chant build src -o dist/template.json, one output, deployed on its own. There is no partition scheme, no layout convention, and nothing multi-stack-specific to learn before you can read one.

That is the point. Someone opening alb-infra/src/alb.ts reads an ALB and can predict what ships, because the unit they opened is the unit that deploys. A splitting convention inside one project would put a translation step between the two.

The gitlab-aws-alb-* examples are two siblings — shared infrastructure, plus the services that ride on it:

examples/
├── gitlab-aws-alb-infra/ ← VPC, ALB, ECS cluster, ECR
│ ├── chant.config.ts
│ └── src/
│ ├── network.ts
│ ├── alb.ts
│ ├── ecr.ts
│ └── outputs.ts ← what this stack publishes
└── gitlab-aws-alb-services/ ← both services on that ALB
├── chant.config.ts
└── src/
├── params.ts ← what this stack consumes
└── services.ts ← two FargateService calls

Every src/ is flat. Every project builds with the same plain command it would use alone.

The API and UI services are one project rather than two because they are one deploy unit: they share every ALB parameter, they are always deployed together, and neither is useful without the listener the other one also hangs off. A stack per service would have bought two independent deploy cadences the example never used, at the cost of a second copy of the same seven parameters. Split by what deploys together, not by what is conceptually separate.

The producer publishes named handles with output():

gitlab-aws-alb-infra/src/outputs.ts
import { output } from "@intentius/chant-lexicon-aws";
import { network } from "./network";
import { shared } from "./alb";
// ALB outputs — service stacks reference these
export const clusterArn = output(shared.cluster.Arn, "ClusterArn");
export const listenerArn = output(shared.listener.ListenerArn, "ListenerArn");
export const vpcId = output(network.vpc.VpcId, "VpcId");

The consumer declares matching parameters:

gitlab-aws-alb-services/src/params.ts
import { Parameter } from "@intentius/chant-lexicon-aws";
// Shared ALB stack outputs — passed via --parameter-overrides at deploy time
export const clusterArn = new Parameter("String", { description: "ECS Cluster ARN" });
export const listenerArn = new Parameter("String", { description: "ALB Listener ARN" });
export const vpcId = new Parameter("String", { description: "VPC ID" });

The join key is the name. "ClusterArn" published by one stack is clusterArn consumed by another, and the value crosses at deploy time — for CloudFormation, --parameter-overrides; each lexicon uses its own provider-native mechanism.

Deploy order follows the reference: the producer first, then the consumers. Each project’s README states its dependency, and consumers read their inputs as ordinary declared parameters — no chant-specific wiring, no shared state.

Each project graphs on its own, and the IR carries both halves of the link:

  • exports — what this stack publishes: the output name and the node producing it.
  • imports — what it consumes: the parameter name and its node.

Matching an import’s name to another stack’s export name reconstructs the cross-stack edge. Composition is a viewer’s job rather than chant’s — chant emits the handles, and a tool pointed at several projects joins them. behold does this: point it at N project directories and it draws one boundary box per project with the cross-stack edges between them.

A project can also declare several stacks itself, each built from its own directory. Use this when the pieces genuinely belong to one project and you want them versioned and reviewed together — not as the default way to split an estate.

chant.config.ts
export default {
lexicons: ["aws"],
stacks: [
{ name: "networking", src: "src/networking" },
{ name: "compute", src: "src/compute" },
],
} satisfies ChantConfig;

Each entry names the deployed stack and the source built for it; region is available for an estate spread across several. Build each explicitly — one chant build <dir> -o <out> per stack.

With stacks set, chant lifecycle snapshot and chant lifecycle diff iterate every stack, building each src scoped so its logical ids match what that stack deploys, and observing each against its own live stack name. Without it, chant assumes a single stack named after the environment. chant graph uses the same declaration: it qualifies node ids <stack>::<id> to line up with observation, and groups them under groups.byStack.

The cost is the one side-by-side projects avoid: a reader has to consult chant.config.ts to learn which directories are deploy units before they can predict anything.

A child project is a subdirectory that builds to a separately-valid template and is referenced explicitly from a parent file. The parent is aware of the child — it uses a lexicon-specific function like nestedStack(), passes parameters in, and reads outputs back, with cross-stack values declared via stackOutput().

Use it when you want one deployable unit that carries its children. For independent stacks, use side-by-side projects.

my-project/
├── src/
│ ├── app.ts ← parent resources + nestedStack() call
│ └── network/ ← child project
│ ├── vpc.ts
│ ├── security.ts
│ └── outputs.ts ← stackOutput() declarations
└── dist/
├── template.json ← parent (AWS::CloudFormation::Stack)
└── network.template.json ← child (standalone template)
src/network/outputs.ts
/**
* Cross-stack outputs — values the parent can reference
*/
import { stackOutput } from "@intentius/chant";
import { vpc, subnet } from "./vpc";
import { lambdaSg } from "./security";
export const vpcId = stackOutput(vpc.VpcId, {
description: "VPC ID",
});
export const subnetId = stackOutput(subnet.SubnetId, {
description: "Public subnet ID",
});
export const lambdaSgId = stackOutput(lambdaSg.GroupId, {
description: "Lambda security group ID",
});
src/app.ts
/**
* App layer — Lambda function in the parent template that references
* the network nested stack's outputs via cross-stack references
*/
import { Function, Sub, AWS, Ref, nestedStack } from "@intentius/chant-lexicon-aws";
// nestedStack() references a child project directory
const network = nestedStack("network", import.meta.dirname + "/network", {
parameters: { Environment: "prod" },
});
export const handler = new Function({
FunctionName: Sub`${AWS.StackName}-handler`,
Runtime: "nodejs20.x",
Handler: "index.handler",
Role: Ref("LambdaExecutionRole"),
Code: { ZipFile: "exports.handler = async () => ({ statusCode: 200 });" },
VpcConfig: {
SubnetIds: [network.outputs.subnetId],
SecurityGroupIds: [network.outputs.lambdaSgId],
},
});
// Re-export so discovery picks it up as an entity
export { network };

The child can be built independently (chant build src/network/), and the parent build produces multiple template files. See your lexicon’s documentation — for AWS, the Nested Stacks guide.

Earlier versions of this guide described a mode where a single chant build src detected subdirectories and emitted one stack per directory automatically, plus a manifest.json.

chant build does not do that. It partitions by lexicon — a project declaring aws and k8s produces one output per lexicon — and writes to the single path you give it with -o. Subdirectories carry no meaning to it.

If you have a project written against the old description, nothing has changed underneath you: it was never auto-partitioning. Give each stack its own project, or its own stacks[] entry and build command.

See Multi-Stack Output for the serialization-level detail.