Skip to main content

DTOs and Builders

Schemas define the shape of our API resources, and can validate responses from the server. Typically, a same or similar shape is also used for requests which create or update resources via POST or PUT requests. In our case, the product shape is the same wether we are retrieving a product, or creating/updating a new one.

DTOs are objects which we send to the server. We can take advantage of the types we Inferred from our schemas.

We can automatically create Builder classes implementing the Builder Pattern using the package @autometa/dto-builder. Since we have already defined our schemas and interfaces, we do not need to redeclare the properties on the class if we extends from the DTO function, typed with our interface:

Create a new file for DTOs, which will implement the interfaces generated by our schema

// src/products/product.dto.ts
import { Product } from "./product.schema";
import { DTO } from "@autometa/dto-builder";

export class ProductDTO extends DTO<Product> {}

Because the products schema only applies to response payloads, we do not need to make a DTO for it.

If we wanted to define default values that are immediately available in the new DTO, we can use the DTO.* decorators. When applied, the builder will automatically add a property to the output object containing the defined value. The value can be a raw primitive or object, a factory function which will be evaluated lazily, or a child DTO which will also be instantiated with all default values;

// src/products/product.dto.ts
import { Product } from "./product.schema";
import { DTO } from "@autometa/dto-builder";

export class ProductDTO extends DTO<Product> {
@DTO.value("Foo Phone")
name: string;

@DTO.factory(() => "A phone that is foo")
description: string;
}

Now make a builder class file

// src/products/product.builder.ts
import { ProductDTO } from "./product.dto";
import { Builder } from "@autometa/dto-builder";

export class ProductBuilder extends Builder(ProductDTO) {}

Using a builder is simple. It has setter methods for each property decorated with @Property, which accept a value of the type of that propert on the underlying dto.

First we want to consider what our Gherkin might look like. We will want to create a new Product DTO using a builder, so we can use Cucumber Expressions to capture a name for the product, and create a new builder with that as the name.

# test/features/products.feature
Feature: Products

Scenario: Create a new product
Given I have a product named "Foo Phone"

We can create a step representing this to prepare our product

// test/steps/foo
import { ProductBuilder } from "../../src/products/product.builder";
import { Given } from "@autometa/runner";

Given("I have a product named {string}", (name, { world }) => {
world.productBuilder = new ProductBuilder().name(name);
});

Let's also declare this property on our world:

// test/world.ts
import { ProductBuilder, ProductResponse } from "../src/products";

export interface World {
productResponse: ProductResponse;
productBuilder: ProductBuilder;
}

We can also apply our builder pattern in ther Gherkin itself. We can use the * step type to add values to our builder.

# test/features/products.feature

Feature: Products

Scenario: Create a new product
Given I have a product named "Foo Phone"
* I have a product with description "A phone that is foo"
* I have a product with price 100
* I have a product with quantity 10

Since the * follow a Given we will define them as Given:

// test/steps/products.steps.ts
import { ProductBuilder } from "../../src/products/product.builder";
import { Given } from "@autometa/runner";

Given("I set the product description to {string}", (description, { world }) => {
world.productBuilder = world.productBuilder.withDescription(description);
});

Given("I have a product with price {int}", (price, { world }) => {
world.productBuilder = world.productBuilder.withPrice(price);
});

Given("I set the product quantity to {int}", (quantity, { world }) => {
world.productBuilder = world.productBuilder.withQuantity(quantity);
});

This is fine but it creates a lot of steps. This doesn't cover all of our properties yet. Since there is a direct reference to a property on our Product in the gherkin string, we can use that to reduce the number of steps we need to write. We can also take advantage of the built in primitive type which will parse a value into a string, number, boolean, date, undefined, or null. The builder has an additonal assign method which accepts a string to map a value to it's intended property

import { ProductBuilder } from "../../src/products/product.builder";
import { Given } from "@autometa/runner";

Given("I have a product named {string}", (name, { world }) => {
world.productBuilder = new ProductBuilder().withName(name);
});

Given(
"I set the product {string} to {primitive}",
(property, value, { world }) => {
world.productBuilder = world.productBuilder.assign(property, value);
}
);

We could further define the property key as it's own Cucumber Expression, so that it maps to keyof Product, which we'll look at in the next section.

Interfaces - Anonymous Object Builders

It might not be desirable to build your object as a class. When not used to extend a class, the DTO function will return an anonymous object builder, with the same interface as the class builder.

import { Builder } from "@autometa/dto-builder";

interface IUser {
id: number;
name: string;
age: number;
}

const UserBuilder = Builder<IUser>();

Deriving a builder and default values

Since anonymous objects cannot be decorated, they cannot accept default values or factories which might change between instantiations.

To work around this, an anonymous builder is derivable. Any values assigned to the builder will stay until the builder is built. However when the derive method is called, a new builder will be created, copying the values from the original. If those values are set agin in the derived builder, they will not affect the original.


const bobBuilder = new UserBuilder().id(1).name("bob").age(23);

const olderBobBuilder = bobBuilder.derive().age(24);