Test Driven Contract Evolution using Effect Schema
From version chaos to clarity. Practical patterns for evolving your domain contracts over time.
Teams building AI-heavy products run into this quickly: the product changes often, the data shape changes with it, and not every consumer upgrades at the same time. A field added for one workflow can quietly become a breaking change for another.
That is usually where contracts start to hurt. Instead of shipping the next change, teams end up thinking about migrations, rollout order, and compatibility across services, tools, or agents that are all moving at different speeds.
What has worked well for me is treating contract evolution as something we design for up front. If decoding happens at a single boundary and older payloads stay in the test suite, we can keep shipping quickly without making every schema change feel like a migration project. To make that boundary trustworthy, I keep a small fixture set of supported payload shapes and verify them in tests, so the contract stays honest as the product grows.
Testing algorithm for contract evolution
-
Keep fixtures for every supported contract shape so older payloads remain part of the test surface and we can catch regressions before they leak into downstream workflows.
-
Decode through a single compatibility boundary so defaults, fallbacks, and transformations stay explicit.
-
Backfill only when a field becomes required by providing a safe default at the decoding boundary.
-
Keep true enhancements optional by modeling their absence explicitly instead of forcing migration.
-
Add a fixture for each change and rerun the full set so every previous version continues to pass.
Assume that we are building a domain model for an e-commerce company. In its most basic form, a product or SKU can be defined like this:
import { Schema } from "effect";
export const Product = Schema.Struct({
id: Schema.UUID,
name: Schema.String,
}); import { Schema } from "effect";
export const Product = Schema.Struct({
id: Schema.UUID,
name: Schema.String,
}); If you want other teams to construct a domain object directly using safe construction, you can use:
export const product = Product.make({
id: "663d804b-cef2-4496-89f0-10093723f9f6",
name: "Trail Mix",
}); export const product = Product.make({
id: "663d804b-cef2-4496-89f0-10093723f9f6",
name: "Trail Mix",
}); As more teams build on this model, they will add their own workflows and constraints around it. Instead of exposing the core domain object directly, we introduce a from function that decodes primitive input into the domain shape at a single boundary.
export const from = (
product: Partial<typeof Product.Encoded>
): typeof Product.Type => Schema.decodeUnknownSync(Product)(product); export const from = (
product: Partial<typeof Product.Encoded>
): typeof Product.Type => Schema.decodeUnknownSync(Product)(product); That gives other teams a stable entry point as long as their input follows the same basic shape.
type Product = {
id: string;
name: string;
}; type Product = {
id: string;
name: string;
}; To make that boundary trustworthy, we keep a small fixture set of supported payload shapes and verify them in tests. Each ProductCase is just a named example of a product payload, so we can see which version of the shape is being exercised without guessing from the code.
type ProductCase {
label: string;
payload: Partial<typeof Product.Encoded>;
}
export const products: ProductCase[] = [
{
label: "a simple product",
payload: {
id: "c7b1c6eb-a76b-417b-89f9-75fd3cca1dea",
name: "Trail Mix",
},
},
]; type ProductCase {
label: string;
payload: Partial<typeof Product.Encoded>;
}
export const products: ProductCase[] = [
{
label: "a simple product",
payload: {
id: "c7b1c6eb-a76b-417b-89f9-75fd3cca1dea",
name: "Trail Mix",
},
},
]; We then add a small composition test to confirm that the from process succeeds for the initial contract. Using test.each lets us run the same assertion against every fixture without repeating the test body, which keeps the check simple as the fixture list grows.
describe("Product", () => {
describe("from", () => {
test.each(products)("should construct from $label", ({ payload }) => {
expect(() => from(payload)).not.toThrow();
});
});
}); describe("Product", () => {
describe("from", () => {
test.each(products)("should construct from $label", ({ payload }) => {
expect(() => from(payload)).not.toThrow();
});
});
}); Required Pricing Scenario
The initial model worked well enough for SKU creation across internal and external vendor flows. The next requirement was pricing, and this time it had to be a required field.
So you model the price like this:
export const Price = Schema.Struct({
currency: Schema.Literal("usd", "cad", "inr"),
amount: Schema.BigDecimal,
});
export const Product = Schema.Struct({
id: Schema.UUID,
name: Schema.String,
price: Price,
}); export const Price = Schema.Struct({
currency: Schema.Literal("usd", "cad", "inr"),
amount: Schema.BigDecimal,
});
export const Product = Schema.Struct({
id: Schema.UUID,
name: Schema.String,
price: Price,
}); At this point, older payloads stop decoding, which means existing producers can no longer create a valid SKU.
Error name: "ParseError"
Error message: "{ readonly id: UUID; readonly name: string; readonly price: { readonly currency: \"usd\" | \"cad\" | \"inr\"; readonly amount: BigDecimal } }\nāā [\"price\"]\n āā is missing"
If pricing is required in the latest model but not yet present everywhere, the compatibility boundary needs to supply a safe default until upstream systems catch up.
We can model that default so both internal construction and external decoding converge on the same value.
import { BigDecimal, Schema } from "effect";
export const DefaultPrice = {
currency: "usd" as const,
amount: BigDecimal.unsafeFromNumber(0),
};
export const Price = Schema.Struct({
currency: Schema.Literal("usd", "cad", "inr"),
amount: Schema.BigDecimal,
}).pipe(
Schema.optional,
Schema.withDefaults({
constructor: () => DefaultPrice,
decoding: () => DefaultPrice,
})
); import { BigDecimal, Schema } from "effect";
export const DefaultPrice = {
currency: "usd" as const,
amount: BigDecimal.unsafeFromNumber(0),
};
export const Price = Schema.Struct({
currency: Schema.Literal("usd", "cad", "inr"),
amount: Schema.BigDecimal,
}).pipe(
Schema.optional,
Schema.withDefaults({
constructor: () => DefaultPrice,
decoding: () => DefaultPrice,
})
); The decoding tests now pass again, but they still do not assert the business expectation behind the change. To make that explicit, we add a small domain utility.
import { BigDecimal, Predicate } from "effect";
export const hasPrice = (product: typeof Product.Type): boolean =>
Predicate.isNotUndefined(product.price) &&
BigDecimal.greaterThanOrEqualTo(
product.price.amount,
BigDecimal.unsafeFromNumber(0)
); import { BigDecimal, Predicate } from "effect";
export const hasPrice = (product: typeof Product.Type): boolean =>
Predicate.isNotUndefined(product.price) &&
BigDecimal.greaterThanOrEqualTo(
product.price.amount,
BigDecimal.unsafeFromNumber(0)
); We also update the fixture so at least one case carries a real price greater than zero.
export const products: ProductCase[] = [
{
label: "a simple product",
payload: {
id: "c7b1c6eb-a76b-417b-89f9-75fd3cca1dea",
name: "Trail Mix",
},
},
{
label: "a simple product with a pricing",
payload: {
id: "c7b1c6eb-a76b-417b-89f9-75fd3cca1dea",
name: "Trail Mix",
price: {
amount: "12",
currency: "usd",
},
},
},
]; export const products: ProductCase[] = [
{
label: "a simple product",
payload: {
id: "c7b1c6eb-a76b-417b-89f9-75fd3cca1dea",
name: "Trail Mix",
},
},
{
label: "a simple product with a pricing",
payload: {
id: "c7b1c6eb-a76b-417b-89f9-75fd3cca1dea",
name: "Trail Mix",
price: {
amount: "12",
currency: "usd",
},
},
},
]; Now we tighten the assertions:
describe("Product", () => {
describe("from", () => {
test.each(products)("should construct from $label", ({ payload }) => {
const product = from(payload);
expect(hasPrice(product)).toBe(true);
});
});
}); describe("Product", () => {
describe("from", () => {
test.each(products)("should construct from $label", ({ payload }) => {
const product = from(payload);
expect(hasPrice(product)).toBe(true);
});
});
}); Optional Shipping Scenario
The next requirement is delivery estimation directly from the product catalog. Older payloads do not include shipping information, and unlike pricing, this is a true optional enhancement. We want to support it without forcing older producers to change immediately.
So you model the shipping information as an optional field in the schema:
import { Schema } from "effect";
export const Shipping = Schema.Struct({
method: Schema.Literal("standard", "express"),
etaDays: Schema.Number,
});
export const Product = Schema.Struct({
id: Schema.UUID,
name: Schema.String,
price: Price,
shipping: Schema.optional(Shipping),
}); import { Schema } from "effect";
export const Shipping = Schema.Struct({
method: Schema.Literal("standard", "express"),
etaDays: Schema.Number,
});
export const Product = Schema.Struct({
id: Schema.UUID,
name: Schema.String,
price: Price,
shipping: Schema.optional(Shipping),
}); We also add a utility that treats missing shipping data as valid, while still verifying the shape when shipping is present.
import { Predicate } from "effect";
export const hasShipping = (product: typeof Product.Type): boolean => {
if (!product.shipping) {
return true;
}
return (
Predicate.isNotUndefined(product.shipping.method) &&
Predicate.isNotUndefined(product.shipping.etaDays)
);
}; import { Predicate } from "effect";
export const hasShipping = (product: typeof Product.Type): boolean => {
if (!product.shipping) {
return true;
}
return (
Predicate.isNotUndefined(product.shipping.method) &&
Predicate.isNotUndefined(product.shipping.etaDays)
);
}; Then we extend the tests so both old and new payloads are verified against the same boundary:
describe("Product", () => {
describe("from", () => {
test.each(products)("should construct from $label", ({ payload }) => {
const product = from(payload);
expect(hasPrice(product)).toBe(true);
expect(hasShipping(product)).toBe(true);
});
});
}); describe("Product", () => {
describe("from", () => {
test.each(products)("should construct from $label", ({ payload }) => {
const product = from(payload);
expect(hasPrice(product)).toBe(true);
expect(hasShipping(product)).toBe(true);
});
});
}); Contract change is unavoidable, but widespread breakage is not. By treating schema decoding as a runtime boundary, introducing deliberate defaults, and keeping older payloads in test fixtures, you create room for the domain to evolve without turning every release into a migration event.
What makes this approach useful is that compatibility stays explicit. Instead of hoping broad integration coverage catches regressions, you can verify how each contract shape behaves and keep those evolution rules close to the domain itself. Over time, that makes delivery faster and contract changes easier to reason about.
Must read
A curated list of follow-up reads that helps readers continue the thread without leaving the article abruptly.
-
Official Docs
Effect Schema DocumentationThe definitive reference for building type-safe schemas with Effect.