August 11, 2020Handling promises in Sagas
August 11, 2020Modern Frontend Architecture Part 1: API
May 5, 2020Frontend Pattern: Route Object
May 1, 2020Frontend skills for the Web Designer
April 10, 2020Learning Frontend Path
January 26, 2020Podcasts I listen to (Jan 2020)
January 23, 2020Are you too old to start programming
January 22, 2020Use tabs not spaces
October 18, 2019Refactoring Overgrown React Components slides
October 2, 2019Book Review: Factfulness
October 1, 2019Yes, it's a good time to add TypeScript to your project
September 24, 2019Code Review – Best Practices, Guidelines & Process Insights
June 10, 2019Why I read on the iPad
March 18, 2019Software & Hardware I use
March 16, 2019Why I dont use React Router Link component
November 15, 2018Why public money software is not open source?
July 5, 2018How learning Angular made me better React developer
May 23, 2018My 12 tips how to increase your frontend coding productivity

Modern Frontend Architecture Part 1: API

I'd like to invite you to a series of articles about building modern architecture for frontend SPA apps, based on React and TypeScript (a must).

The architecture is build on layers and building blocks, inspired by some DDD concepts. The goal is to make a scalable architecture without any hard constraints. I will show examples of file structure, but the most important part is how to separate concerns and communicate between them.

In this part I will introduce the basics and focus on designing the API layer

The main structure

The architecture doesn't care if you use CRA or other boilerplate. The top building blocks look as following

app structure

app/

app/ is the bootstrap & infra layer. I will describe it with details in the future. Basically this is where you keep setup of Redux, Router etc. Technically this is core of the app which should build properly with only these files.

config/

config/ is optional, but probably required top level config directory where you can setup services configuring the app, e.g. theming. In some apps this can be omitted in favor of .env, but for more complex ones config will be probably another layer.

domain/

domain/ is a directory containing business domain. This is where you solve actual problems and deliver features. Domain contains several domains and Shared domain which is... shared.

domains structure

For example, classic e-commerce app will have following domains:

  • Cart
  • Checkout
  • Product
  • Category
  • Account
  • ...etc

The Domain structure

Each domain is built of several blocks + contain non categorized files specific to this domain.

the domain structure

I will describe each block in the future and focus on API in this article.

The API layer

api structure

DomainApi

I prefer to name it like Domain.api.ts, which will be e.g. Checkout.api.ts. This is a class / an object containing methods for http requests from/to our backend API.

This file has a single dependency - Domain.api.dto.ts which is a declaration of api contracts.

Example class for Cart can look like this

// Cart.api.ts
export class CartApi {
  constructor(private http: ICanDoHttp) {}

  fetchItems(): Promise<CheckoutApiDto.FetchItems.Response.AnyResponse> {
    return this.http.get("/cart").catch(ParsedError.fromApiResponse);
  }

  addToCart(itemID: string): Promise<CheckoutApiDto.AddToCart.Response.AnyResponse> {
    const requestDto: CheckoutApiDto.AddToCart.Request.DefaultRequest = {
      itemID: itemID
    }

    return this.http
      .put("/cart", requestDto)
      .catch(ParsedError.fromApiResponse);
  }
}

Notes on this snippet:

constructor(private http: ICanDoHttp) {} - here I use dependency injection to provide a HTTP client. Technically it can be an overkill, because I don't think I would ever need to inject another instance.

: Promise<CheckoutApiDto.FetchItems.Response.AnyResponse> - TypeScript allows only to type resolved promise, so I do. For errors I do...

catch(ParsedError.fromApiResponse) - which is wrapper on error with common interface - I will write about this pattern in the future.

.put("/cart", { itemID: itemID }) - I always map fields here. If API changes, I can change contract in single place. If request object is more complex, I put it to the Mapper and DTO.

DomainApiDto

I prefer to name it Domain.api.dto.ts, so it will be e.g. Cart.api.dto.ts. This is a contract between API and frontend. When I look up the swagger I always start with creating such a file in the beginning.

It should cover all used endpoints (possibly many variants per endpoint), can include Request and Response namespaces, which contain several variants, depending on what can be returned.

Additionally it can contain:

  • Validation (eg. Zod, RunTypes)
  • Helper methods
export declare namespace CartApiDto {
	export namespace FetchItems {
		export namespace Response {
			type SingleCartItem = {
				id: string;
				// ... Some attributes
			};

			export type CartItemsSuccess = SingleCartItem[];
			export type AnyResponse = CartItemsSuccess;
		}
	}

	export namespace AddToCart {
		export namespace Request {
			export type DefaultRequest = {
				itemID: string;
			};
		}

		export namespace Response {
			export type ItemAddedToCartSuccess = {
				id: string;
			};

			export type ItemAddingToCartFailed = {
				code: string;
				message: string;
			};

			export type AnyResponse = ItemAddedToCartSuccess | ItemAddingToCartFailed;
		}
	}
}
export const CartApiDto = {
	AddToCart: {
		Request: {
			DefaultRequest: {
				validateItemID(id: string) {
					if (id.length < 10) {
						throw new Error('Item ID is always >= 10 characters');
					}
				}
			}
		}
	}
};

Following lines might need some explaination: export type AnyResponse = CartItemsSuccess; - Even if there is single response available for now, I like to alias them, so later I can add more without breaking API

export type AnyResponse = ItemAddedToCartSuccess | ItemAddingToCartFailed; - errors can (should) be typed as well, incuding typed enums for codes, so frontend can map code to validation message or apply custom fallback strategy.

export type AnyResponse = ItemAddedToCartSuccess | ItemAddingToCartFailed; - AnyResponse will be checked in Saga to decide what to do (sae items, show error).

validateItemID(id: string) - Helpers like this can add extra validators etc. to check if request is properly constructed for the backend.

ApiMapper

One of the core rules of this architecture is to draw a line between backend api data and frontend data. The first is called ApiDto, the latter - Model.

From my experience it's very hard to coordinate API changes between teams, especially in startup environments. That's why there should be a single layer where data from backend is translated to data used by frontend. Change in API contract should never impact data on frontend.

Mapper has a dependency from model (will be described later) and DTO. Technically mapper map model to DTO or DTO to model.

Example file will look like this:

export const CartApiMapper = {
	mapCartItemsDtoToModel(
		dto: CartApiDto.FetchItems.Response.CartItemsSuccess
	): CartModel.CartItem[] {
		return dto.map((item) => ({
			id: item.id,
			quantity: item.amount
			// ...etc
		}));
	}
};

Here mapper is an isolation from DTO structure to local model we use. The structure of model doesn't matter in this example.

What is important is that our app can internally rely on quantity, even if backend change property to amount - we are safe. We can also easily create several mappers for several Api methods, eg. v1, v2, v3. You can use all of them at the same time, mapping to the same interface. So it's easy to migrate.

ApiMock

Api mock, typically Cart.api.mock.ts is representing a mock for every endpoint we use. It's important for projects I use for several reasons

  • Easy to write integration tests, by stubbing what is coming back from the API
  • Easy to test different scenarios on development server, to test various api responses
  • Ability to write frontend features before backend is ready

I use msw most often, but it can be as well axios-mock-adapter or anything you like - it doesn't matter from architecture POV.

Typical mock will look like this

class CartApiMock {
	constructor(private server = createServer()) {}

	static getMockedCartDto(): CartApiDto.FetchItems.Response.CartItemsSuccess {
		return [
			/* ... */
		];
	}

	static resolveGetCartItemsMock(
		items: CartApiDto.FetchItems.Response.CartItemsSuccess = this.getMockedCartDto(),
		status = 200
	) {
		return rest.get((req, res, ctx) => {
			return res(ctx.status(status), ctx.json(items));
		});
	}

	doMockGetCartItems() {
		this.server.use(this.resolveGetCartItemsMock());
	}

	// other mocks
}
  • getMockedCartDto - returns raw data in DTO interface, so it can be used manually
  • resolveGetCartItemsMock - can get REST mock e.g. for using it for another instance of server
  • doMockGetCartItems - will use REST mock and apply it, so it hides server setup

Of course real mock files are more complex, I include there some mappers, overwriting data etc. The important thing is to create a specific layer for this.

Summary

Here is the first part. The API block carries it's responsibilies and does it well:

  • Communicates with backend (and other services)
  • Creates adapters for data to ensure stability of data structures
  • Provides mocks for it's own so other layers can test easily
  • Explicitely declares communication contracts

In next articles I will describe other layers and how they communicate to each other.