Modular infrastructure
As your infrastructure grows, organizing code into modules becomes essential. This guide shows you how to structure formae into reusable components, share configuration across files, and build composable infrastructure patterns.
Prerequisites: Understand the basics from Fundamentals before diving into modular patterns.
Why modular infrastructure?
Breaking infrastructure into modules provides several benefits:
- Reusability - Write once, use in multiple formae
- Team ownership - Different teams can own different modules
- Maintainability - Smaller files are easier to understand and modify
- Testing - Test modules independently
- Composition - Combine modules to build complex infrastructure
Shared configuration patterns
The vars.pkl pattern
Most formae projects use a shared vars.pkl
file for common configurations like stack, target, and properties:
// vars.pkl
import "@formae/formae.pkl"
import "@aws/aws.pkl"
properties {
name = new formae.Prop {
flag = "name"
default = "my-project"
}
region = new formae.Prop {
flag = "region"
default = "us-east-2"
}
}
// Shared across all your formas
stack: formae.Stack = new {
label = properties.name.value + "-stack"
description = "My project infrastructure"
}
target: formae.Target = new formae.Target {
label = "default-aws-target"
config = new aws.Config {
region = properties.region.value
}
}
Then import and use in your formas:
// main.pkl
amends "@formae/forma.pkl"
import "@formae/formae.pkl"
import "@aws/s3/bucket.pkl"
import "./vars.pkl"
forma {
vars.stack // Use the shared stack
vars.target // Use the shared target
// Your resources here
new bucket.Bucket {
label = "my-bucket"
bucketName = "my-app-" + properties.name.value
}
}
This pattern keeps your stack and target configuration in one place, making it easy to apply consistent settings across multiple formae.
Tip: Using a shared
vars.pkl
is especially helpful when you have multiple formae that deploy to the same stack or use the same AWS account/region configuration.
Organizing infrastructure code
formae supports two approaches for creating reusable infrastructure. Choose the one that fits your use case.
Class-based modules
Classes encapsulate related resources and expose a clean API. Use classes when:
- Building multi-component infrastructure (VPC + subnets + security groups)
- Passing resources between components (VPC → Database)
- Hiding implementation details from consumers
Function-based modules
Functions create resources with a clear flow. Use functions when:
- Building straightforward infrastructure without complex composition
- Creating utility functions for repetitive patterns
Building with classes
Classes are Pkl's native way to organize code. They're perfect for infrastructure because they encapsulate related resources and expose a clean API.
A simple networking class
// infrastructure/networking.pkl
import "@aws/ec2/vpc.pkl"
import "@aws/ec2/subnet.pkl"
import "@aws/ec2/securitygroup.pkl"
class Networking {
name: String
vpcCidr: String
region: String
hidden vpc: vpc.VPC = new {
label = "\(name)-vpc"
cidrBlock = vpcCidr
enableDnsHostnames = true
enableDnsSupport = true
tags {
new { key = "Name"; value = "\(name)-vpc" }
}
}
hidden publicSubnet: subnet.Subnet = new {
label = "\(name)-public-subnet"
vpcId = vpc.res.id
cidrBlock = "10.0.1.0/24"
availabilityZone = "\(region)a"
tags {
new { key = "Name"; value = "\(name)-public-subnet" }
}
}
hidden appSecurityGroup: securitygroup.SecurityGroup = new {
label = "\(name)-app-sg"
vpcId = vpc.res.id
groupDescription = "Application security group"
tags {
new { key = "Name"; value = "\(name)-app-sg" }
}
}
resources: Listing = new {
vpc
publicSubnet
appSecurityGroup
}
}
Use it in your forma:
// main.pkl
amends "@formae/forma.pkl"
import "./infrastructure/networking.pkl"
import "./vars.pkl"
local network = new networking.Networking {
name = properties.name.value
vpcCidr = properties.vpcCidr.value
region = properties.region.value
}
forma {
vars.stack
vars.target
...network.resources
}
Composing classes
Pass resources between classes to build layered infrastructure:
// VPC class creates networking resources
class VpcResources {
team: String
hidden vpc: vpc.VPC = new { ... }
hidden subnet1: subnet.Subnet = new { ... }
hidden subnet2: subnet.Subnet = new { ... }
resources: Listing = new { vpc; subnet1; subnet2 }
}
// Database class receives VPC resources
class DatabaseResources {
team: String
vpc: vpc.VPC // Pass in VPC from another class
subnet1: subnet.Subnet
subnet2: subnet.Subnet
hidden dbSubnetGroup: dbsubnetgroup.DBSubnetGroup = new {
subnetIds { subnet1.res.subnetId; subnet2.res.subnetId }
}
resources: Listing = new { dbSubnetGroup; /* ... */ }
}
// Use them together
local network = new vpc_resources.VpcResources { team = "platform" }
local database = new database_resources.DatabaseResources {
team = "platform"
vpc = network.vpc
subnet1 = network.subnet1
subnet2 = network.subnet2
}
forma {
...network.resources // Spread operator expands the Listing
...database.resources
}
Spread operator: Use
...
to expand aListing
into individual resources. Works for both classes and functions.
The outer keyword
When nesting classes, use outer
to reference parent properties:
class MLPlatform {
name: String
region: String
accountId: String
hidden networking: networking.Networking = new {
name = outer.name // Reference parent's name
region = outer.region // Reference parent's region
}
hidden storage: storage.Storage = new {
name = outer.name
region = outer.region
accountId = outer.accountId
}
}
Function patterns
Functions work well for resource creation that follows a clear flow:
// Simple resource creation
function vpc(properties: Dynamic?): vpc.VPC = new vpc.VPC {
label = "main-vpc"
cidrBlock = properties.vpcCidr.value
enableDnsHostnames = true
}
// Utility functions
function subnetCidr(base: String, index: Int): String =
base.replaceAll("/16", "/24").replaceLast(".0", ".\(index)")
function tags(name: String, env: String): Listing = new Listing {
new { key = "Name"; value = name }
new { key = "Environment"; value = env }
new { key = "ManagedBy"; value = "formae" }
}
Imports and references
Import types
// Formae framework
amends "@formae/forma.pkl"
import "@formae/formae.pkl"
// AWS resource types
import "@aws/ec2/vpc.pkl"
import "@aws/s3/bucket.pkl"
// Your modules (relative paths)
import "./infrastructure/networking.pkl"
import "./vars.pkl"
import "../shared/storage.pkl"
Referencing resources
Use .res
to access resource attributes:
new subnet.Subnet {
vpcId = myVpc.res.id // Reference resource ID
routeTableId = myRT.res.id
}
new policy.Policy {
policyDocument {
["Resource"] = myBucket.res.arn // Reference resource ARN
}
}
Note: The
.res
mechanism automatically handles dependency ordering. Learn more in Res.
What's next
You now know how to organize infrastructure using both classes and functions. Choose the approach that matches your use case. Next steps:
- Practical example - See function-based patterns in the Lifeline example
- Classic GitOps - Learn deployment workflows and apply modes
- Core Concepts: Stack - Understand how stacks organize resources