End-to-end Type Safety in Clean Architecture
Let's create a completely type-safe web application in a Clean Architecture using a couple of modern libraries. Such systems are orders of magnitude more reliable than the untyped counterparts. Plus, they are easier to understand, maintain and refactor. Tech: TypeScript, GraphQL, MongoDB, React.
💡 The sample code is on Github: https://github.com/thekarel/best-practices-example
End to End Type Safety
In this post I show you how to build a full-stack web application that is type-safe across all layers of the architecture: from the domain models to the services, repositories, the HTTP transport layer (GraphQL) and client-side UI. It's more than just typing up every interface in the codebase: in this example, all significant interfaces and types are derived from higher-order ones - usually from the domain modules.
There is only a single source of truth for types
This means that changes to high-level interfaces cascade through the whole stack. The type checker will be able to spot a mismatch in any one of the layers.
Benefits
The practical benefits are pretty significant:
- The linter warns you of potential bugs before you even run a test or let alone build the app
- You need to write far fewer unit tests than otherwise because the whole codebase relies on interconnected type definitions.
- The codebase is easier to understand as the same interfaces are repeated (maybe with slight modifications)
- Since everything is typed the code is self-documenting
- When you change the code - fix, refactor or improve - you get instant feedback about the health of your codebase in the IDE or by running
tsc
.
Experience shows that even large refactoring can be successfully done on such codebase, solely based on static type checking. Of course, it is not a substitute for End-to-end tests.
All in all, I think such stack eliminates some significant sources of bugs that would otherwise exist because the codebase complexity exceeds a limit. We're incapable of remembering every data shape, type and interface. Apart from fewer bugs, you'd also benefit from higher confidence and faster development throughput. Win-win?
Clean Architecture TL;DR
The architecture of this example follows Clean Architecture principles.
This, in a nutshell, means that:
- The app is sliced into layers, starting from the deepest: domain (entities), services (use cases), transport (GraphQL in this case), repository (abstraction over MongoDB), UI (React, closest to the user)
- There is a strictly unidirectional dependency arrow: layers that are deeper in the stack can never refer to any code in outer layers
The second rule implies that the domain module will never import or refer to anything defined in other layers. The services receive "tools" to get and save data (etc.) via dependency injection. The repository can know about domain entities (but not much else). The transport layer is a smart cookie and knows about the domain, services and repositories (this is the dependency injection root). The UI, ideally, is limited to the GraphQL types, and maybe the domain entities.
The original Clean Architecture diagram. Image from https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
Head over to https://thekarel.gitbook.io/best-practices/the-big-picture/architecture for a detailed treatment.
Tech Stack
Reminder: there is a complete example available at https://github.com/thekarel/best-practices-example
The technologies and libraries I use are the following:
I assume you are relatively familiar with most of these tools already. I'll focus on two libraries that are probably not so widely used and also highlight a couple of essential solutions.
Let's look at each layer one by one and see how this stack hangs together.
Layers
Domain
Technically this is the simplest slice of the stack. The entities are pure TypeScript interfaces. For example, an Order
looks like this:
import {Cupcake} from '../cupcake/Cupcake'
export interface Order {
id: string
customerName: string
deliveryAddress: string
items: Cupcake[]
}
The corresponding Cupcake
is
import {Feature} from './Feature'
import {Kind} from './Kind'
export interface Cupcake {
kind: Kind
features: Feature[]
name: string
}
The critical fact is that all subsequent layers will refer back to these definitions in some shape or form.
Services
The Service layer, also known as Use Cases defines the possible operations on our entities. In this example, these include creating and reading Order
s.
The domain entities are very abstract, but you might be thinking: creating an order is a concrete operation and must be able to talk to a database etc. This fact seems to contradict the dependency arrow rule mentioned above.
Dependency Arrow Rule: Layers that are deeper in the stack can never refer to any code in outer layers
The solution is to define dependency interfaces in the Service layer. For example, the OrderService
defines an OrderRepository
interface. This way the service itself will not have to know anything about the way orders are stored, but can dictate the shape of data going in and coming out of a repository - the rest is an implementation detail, from this point of view:
import {Order} from '@cupcake/domain'
export interface OrderRepository {
connect(): Promise<void>
save(order: Order): Promise<void>
load(id: string): Promise<Order | undefined>
all(): Promise<Order[]>
}
In terms of end to end type safety, please note how the save
method takes a domain Order and similarly how the load
method returns one. This ensures that we can use different storage methods without breaking the contract (see below).
The domain interfaces reappear in similar ways across the whole stack.
Repository
As hinted above, the repository is a data persistence abstraction. Since it implements a higher-level interface definition, we can use different storage strategies in our app depending on the circumstances. Compare the following two repository implementations: one saves into memory, the other into a real database:
OrderRepositoryMemory
import {OrderRepository} from '@cupcake/services'
import {Order} from '@cupcake/domain'
export class OrderRepositoryMemory implements OrderRepository {
private orders: Map<string, Order> = new Map()
async connect() {
return
}
async save(order: Order) {
this.orders.set(order.id, order)
}
async load(id: string) {
return this.orders.get(id)
}
async all() {
return Array.from(this.orders.values())
}
}
OrderRepositoryMongo
import {Order} from '@cupcake/domain'
import {OrderRepository} from '@cupcake/services'
import {Collection, MongoClient} from 'mongodb'
export class OrderRepositoryMongo implements OrderRepository {
client: MongoClient
dbName = 'cupcakes'
collectionName = 'cupcakes'
collection?: Collection<Order>
constructor(private readonly url: string) {
this.client = new MongoClient(this.url, {useUnifiedTopology: true})
}
async connect() {
await this.client.connect()
this.collection = this.client.db(this.dbName).collection<Order>(this.collectionName)
}
async save(order: Order) {
if (!this.collection) {
throw new Error('Connect first')
}
await this.collection.insert(order)
}
// etc
}
Look at the example codebase to see how different repositories are injected
Another equally important fact to note is that all type definitions are picked up from the domain and services layers.
Probably the most significant feature in the type safety context is the fact that we enforce the database documents' shape to match the domain entities:
this.collection = this.client.db(this.dbName).collection<Order>
This is to ensure the primary rule of persistence in Clean Architecture:
A repository takes a domain entity and returns a domain entity or a list of entities
The type-safety of the database layer itself is an important fact: it guarantees that the data entering our system (from the outside world) will match the expected domain shape. In other words, we ensure that everything inside the application boundaries is of known shape.
GraphQL
The example codebase uses GraphQL as the transport layer solution.
GraphQL types are sometimes defined using the "GraphQL schema language", for example:
type Customer {
name: String!
address: String!
}
Using the schema language has one serious disadvantage: it's not possible to refer to domain types using GraphQL's schema. It's time to look at...
TypeGraphQL
TypeGraphQL allows us to define GraphQL schemas using TypeScript classes. Using implements
we can then refer back to domain interfaces. For example, this is how a Cupcake
interface looks like in the example Graph:
import {Cupcake as DomainCupcake, Order as DomainOrder} from '@cupcake/domain'
import {Field, ID, ObjectType} from 'type-graphql'
import {Cupcake} from '../cupcake/Cupcake'
@ObjectType()
export class Order implements DomainOrder {
@Field(() => ID)
id!: string
@Field()
customerName!: string
@Field()
deliveryAddress!: string
@Field(() => [Cupcake])
items!: DomainCupcake[]
}
Generating the final schema from these classes is trivial (don't worry about the container, it has nothing to do with type-safety):
import {AwilixContainer} from 'awilix'
import {buildSchemaSync} from 'type-graphql'
import {OrderResolver} from './order/OrderResolver'
export const generateSchema = (container: AwilixContainer) =>
buildSchemaSync({
resolvers: [OrderResolver],
container: {
get: (constructor) => container.build(constructor),
},
})
The Graph imports the domain type definitions and turns them into strong guarantees: anyone sending a Cupcake
to the server must conform to the domain schema (or the request is rejected). What we achieve with this is significant the same way as it was for the repository: the data coming into our system from the outside world is guaranteed to match our expectations.
The data coming into our system from the outside world is guaranteed to match our expectations
UI
The example app uses a React UI - but any UI library would work.
The crucial question is instead, how do we map from our Graph or domain entities to definitions that are useable in the UI?
Ideally, the UI only knows about the Graph interfaces: these are the "things" that are sent towards the client, and in turn, this is what the client sends back.
Ideally, the UI only knows about the Graph interfaces
GraphQL being what it is, there are other, more intricate questions concerning queries and mutations - it can get complicated quickly. Manually copying all these interfaces from Grap to the UI codebase, and keeping them updated is hopeless.
Hence, we look at the last piece of the puzzle: generating static TypeScript types from GraphQL schemas.
GraphQL Codegen
GraphQL Code Generator is a CLI tool that can generate TypeScript typings out of a GraphQL schema.
The implementation is relatively simple and it only touches the UI project.
First, define a configuration file in `ui/codegen.yml`:
schema: http://localhost:8888/
generates:
src/graphQLTypes.ts:
hooks:
afterOneFileWrite:
- prettier --write
plugins:
- typescript
- typescript-operations
config:
namingConvention:
enumValues: keep
Add a command to package.json:
"scripts": {
"typegen": "graphql-codegen"
}
When you know that the GraphQL schema has changed - this is easy in a monorepo - run the typegen
command in the UI to generate a local type definition of the Graph types. You commit these to the codebase just like any hand-written code.
Having access to these types enables UI components to refer to the Graph types when making a request or creating a payload:
import {Feature, Kind, MutationCreateOrderArgs, Query} from '../graphQLTypes'
// later
const [fetchOrders, ordersFetchStatus] = useManualQuery<{orders: Query['orders']}>(ordersQuery)
React.useEffect(() => {
fetchOrders()
}, [])
const dumbOrderArgs: MutationCreateOrderArgs = {
deliveryAddress: 'New York',
customerName: 'Mr. Muffin',
items: [
{kind: Kind.savoury, features: [Feature.sugarFree], name: 'One'},
{kind: Kind.sweet, features: [Feature.vegan], name: 'Two'},
{kind: Kind.sweet, features: [Feature.exclusive], name: 'Three'},
],
}
The End
As with any code example, this is a slight simplification. Life is always a bit different and undoubtedly more challenging. I haven't touched the topic of evolving interfaces (data shapes), for example. Still, I think these ideas and tools provide a solid foundation to build on.
Relying on clean architecture and a type-safe codebase will make the products we make better and our lives more comfortable at the same time.
The more complex you build, the more type safety you need!
Have I missed something? Please let me know!