Skip to content

pfichtner/pacto

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

pacto: a Java DTO Pact generator

Java CI with Maven Maven Central Codecov Pact

Generate Pact contracts directly from your DTOs — keep tests simple, reliable, and always in sync.

pacto is a Java library/project that allows you to generate Pact contracts directly from your Data Transfer Objects (DTOs). Using pacto, you can define DTOs with concrete values and flexible matchers, then automatically produce Pact contracts for consumer-driven contract testing.


Features

  • Generate Pact contracts directly from Java DTOs.
  • Supports nested DTOs.
  • Flexible matching with stringType, integerType, regex, and other matchers.
  • Configurable matching mode – define specs as strict (exact values must match) or lenient (only types must match).
  • Simplifies consumer-driven contract testing.
  • Easy integration with existing DTO-based projects.

Why use pacto?

Contract testing with Pact is powerful—but writing and maintaining Pact DSLs by hand can be repetitive and error-prone. pacto solves this by generating contracts directly from your DTOs.

Key benefits:

  • 🔄 No duplication – Contracts are generated from the same DTOs you already use.
  • 🛡️ Always in sync – DTO changes automatically flow into contracts, reducing drift.
  • Less boilerplate – No more hand-writing verbose Pact DSL code.
  • 🎯 Robust matchers – Define flexible rules (regex, stringType, etc.) for realistic contracts.
  • 🧩 Supports complex models – Works seamlessly with nested DTOs.
  • 🚀 Easy adoption – Integrates into existing Java projects with minimal setup.

With pacto, you get reliable consumer-driven contract tests powered by Pact with less effort and fewer mistakes.


The problem: duplication without pacto

// DTO definitions — the single source of truth for both examples
@Data
@Accessors(chain = true, fluent = true)
public class PersonDTO {
    private String givenname;
    private String lastname;
    private int age;
    private AddressDTO address;
}

@Data
@Accessors(chain = true, fluent = true)
public class AddressDTO {
    private int zip;
    private String city;
}

Without pacto, you need to define your data structures twice — once in your DTO, and once again in Pact’s DSL.

❌ Classic Pact DSL (manual & repetitive):

// And again in Pact DSL:
RequestResponsePact pact = ConsumerPactBuilder
    .consumer("SomeConsumer")
    .hasPactWith("SomeProvider")
    .uponReceiving("POST /person")
    .path("/person")
    .method("POST")
    .body(new PactDslJsonBody()
        .stringMatcher("givenname", "[A-Za-z'- ]{2,128}", "Givenname")
        .stringMatcher("lastname", "[A-Za-z'- ]{2,64}", "Lastname")
        .integerType("age", 42)
        .object("address")
            .integerType("zip", 12345)
            .stringType("city", "Berlin")
        .closeObject()
    )
    .willRespondWith()
    .status(200)
    .body(new PactDslJsonBody()
        .id("id", 123)
        .stringType("givenname", "[A-Za-z'- ]{2,128}", "Givenname")
        .stringType("lastname", "[A-Za-z'- ]{2,64}", "Lastname")
        .integerType("age", 42)
        .object("address")
            .integerType("zip", 12345)
            .stringType("city", "Berlin")
        .closeObject()
    )
    .toPact();

If your DTO changes (e.g., adding country to AddressDTO), you must update both places or your contract drifts out of sync.

✅ With pacto: model-driven, type-safe and DRY:

PersonDTO person = spec(new PersonDTO())
	.givenname(stringMatcher("[A-Za-z'- ]{2,128}", "Givenname"))
	.lastname(stringMatcher("[A-Za-z'- ]{2,64}", "Lastname"))
	.age(integerType(42))
	.address(
	    like(new AddressDTO())
	        .zip(12345)
	        .city("Berlin")
	);

RequestResponsePact pact = ConsumerPactBuilder
    .consumer("SomeConsumer")
    .hasPactWith("SomeProvider")
    .uponReceiving("POST /person")
    .path("/person")
    .method("POST")
    .body(dslFrom(person))
    .willRespondWith()
    .status(200)
    .body(dslFrom(spec(person).id(id(123))))
    .toPact();

Now, your contract is generated directly from the DTO — no duplication, no drift, no extra maintenance.
No DTO duplication → single source of truth.

  • Compile-time safety; fewer typos.
  • Nested objects & arrays handled automatically.
  • Auto-update contracts when DTO changes.
  • Concise matcher syntax.
  • Reusable and composable matchers.
  • Reduced risk of contract drift.

With pacto, you avoid duplication, reduce boilerplate, and ensure your contracts stay in sync with your DTOs.

Strict vs. Lenient Specs

By default, pacto generates strict specs:

  • Fields must match both type and exact value.
  • Example: if you set setAge(42) the generated contract requires the provider to return exactly 42.

With lenient specs:

  • Fields must match only the declared parameter type of the DTO setter.
  • Example: if you set setAge(42) and the setter accepts a int, the generated contract accepts any int value.

Important: Matchers vs. Concrete Values

The matching mode only affects concrete values.
If you use an explicit matcher, it always behaves as a matcher, independent of the mode:

Mode Example call Contract behavior
strict setAge(integerType(42)) Must match any integer (value ignored)
lenient setAge(integerType(42)) Must match any integer (value ignored)
strict setAge(42) Must match exactly 42
lenient setAge(42) Must match any value of the setter’s type (e.g. any int)

👉 In short: modes matter only when you pass concrete values.
Explicit matchers always define the rules directly.

How to use pacto?

pacto is available on Maven Central. You can include it as a dependency:

<dependency>
  <groupId>io.github.pfichtner</groupId>
  <artifactId>pacto</artifactId>
  <version>0.0.7</version>
</dependency>

⚙️ Dependency Note

pacto builds on the Pact JVM library but does not bundle it.
You need to include Pact yourself in your project’s dependencies.

<!-- Required: Pact JVM (consumer-junit5) -->
<dependency>
  <groupId>au.com.dius.pact.consumer</groupId>
  <artifactId>junit5</artifactId>
  <version>4.6.17</version>
</dependency>

Matchers

pacto supports a rich set of matchers to make your contracts robust and expressive.

Standing on the shoulders of giants: pacto more or less acts as syntax sugar (though technically it has to capture the arguments passed to the matcher static methods) and delegates to the Pact JVM matchers under the hood. You benefit from the full power and documentation of Pact itself.

  • nullValue() – Matches a null value.
  • equalsTo(value) – Match exact value
  • id(int|long) – Matches an ID (special alias for integer types)
  • stringType() / stringType("example") – Matches any string.
  • stringMatcher("regex", "example") – Matches strings with a regex pattern.
  • includeStr("example") – Matches strings that include the given substring.
  • integerType() / integerType(int|long) – Matches any integer number type.
  • decimalType() / decimalType(double|float) – Matches any floating point number type.
  • numberType(Number) – Matches any number type.
  • booleanType() / booleanType(boolean) – Matches any boolean.
  • booleanValue(boolean) – Matches a specific boolean value.
  • hex() / hex(String) – Matches any hex value.
  • uuid() / uuid(String|UUID) – Matches any UUID.
  • time(("format"), LocalDateTime|Date) - Matches times given the format
  • date(("format"), LocalDate|Date) - Matches dates given the format
  • datetime(("format",) LocalDate|Date) - Matches datetimes given the format
  • ipAddress() – Matches any IP address
  • matchUrl("basepath"(, "fragments")) – Matches URL structures
  • eachLike(value) – Matches an array with at least one element like value.
  • minArrayLike(value, min) – Array with at least min elements like value.
  • maxArrayLike(value, max) – Array with at most max elements like value.

🚫 Limitations

Due to JVM and Byte Buddy restrictions, there are some constraints:

  • Constructors cannot be intercepted

  • Pacto creates subclass proxies to record method calls.

  • Original constructor logic runs normally and cannot be intercepted or recorded.

  • Example: new MyDto("Jon", "Doe") cannot have its constructor call recorded.

  • Records and final classes cannot be proxied (currently)

  • Java records are final and have canonical constructors that cannot be overridden.

  • Pacto does not currently support proxies for records or final classes. Theoretically, they could be proxied via JVM instrumentation or a Java agent, but this is not implemented.

Implications for users:

  • DTOs to proxy must be non-final, non-record classes.
  • Only method calls can be recorded; constructor execution cannot.

Disadvantages / Drawbacks

While pacto simplifies contract generation, there are a few considerations:

  • ⚠️ Unsupported field types: Most common fields (strings, numbers, booleans, nested DTOs) are handled automatically. For custom or complex types, you may need to provide manual mapping or custom matcher logic.
  • ⚠️ Manual updates for special cases: If a DTO contains unsupported fields and you change them, you'll need to adjust the contract manually.
  • ⚠️ Additional abstraction: pacto introduces an extra layer on top of Pact JVM. While convenient, this means debugging or understanding matcher behavior may require looking at both pacto and underlying Pact documentation.
  • ⚠️ Learning curve: Users need to understand the pacto DSL and its mapping conventions, even if they are already familiar with Pact JVM.

In most standard scenarios, these drawbacks are minor compared to the benefits of avoiding duplication, reducing boilerplate, and keeping contracts in sync with DTOs.


Contributing

Contributions are welcome! Please open an issue or submit a pull request.


About

Pacto — turning your DTOs into Pact contracts

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •  

Languages