Handling relationships in models

Tue, 28 Apr 2020 00:00:00 +0000

Models are the core of our applications and need to be defined with care. Yet they very often transform themselves into big monolithics becoming more and more complex while being less and less easy to use over time. But why is that?

Let's say we're working on a e-commerce app where people can buy products and leave comments on them. Modeling this should be easy, right?

struct Product {
  let id: Int
  let name: String
  let description: String
  let price: Price
}

struct Comment {
  let id: Int
  let createdAt: Date
  let text: String
  let product: Product
}

At first glance we might think our modeling is correct: we have information about the Comment itself along with the Product it is related to.

Now let's say we can select a product want to show a bunch of its comments:

func getComments(of product: Product) -> Future<[Comment]> {
  ... load comments
}

We might find our first issue here. If you're using a REST API, chances are the API will return a list of comments but not the related product. In that case you may need to make map comments with the product before returning it:

func getComments(of product: Product) -> Future<[Comment]> {
  return builder
    .query(.comments(of: product))
    .request()
    .map { Comment(id: $0.id, createdAt: $0.createAt, text: $0.text, product: product) }
}

We now have our Comment objects. Expect our Product is repeated on each of them. Not really useful as it is going to be the same every time. It also introduce a subtle yet real "vulnerability" into our code: we can have a list of Comment having different Products. Which is not what we are looking for 😕.

Perhaps the first workaround most people will think about is reversing relationship by making Comment a child of Product:

struct Product {
  let id: Int
  ...
  let comments: [Comment]
}

Using this technique we indeed don't repeat Product for each Comment! 👏 However if we take a closer look we see a new issue: we need comments to get our product 😨.

We may decide to just stick to an empty array and load comments only when necessary. While working it would yet introduce new issues or questions:

1. How to differentiate between a product with no comments and a product whose comments have not been loaded yet?

2. How about a single relationship attribute? Does it mean it would need to be optional even when it should not?

3. How to reconciliate product and its comments once they are loaded? (our comments are declared with a let)

None of these two solutions work without introducing drawbacks. Bu if neither Comment nor Product can carry the relationship then where should it go?

Breaking the bound

Comment and Product are two distinct objects that sometimes share a relation: the relation exist but we are not always interested in.

Just like a parent and a child: while there is an obvious relationship between the two of them, parents do not always want to carry their child with them. In the meantime the child should be able to live its life without having its parents around him all the time. In other words: relationships should not be what define models.

The correct way to define relationships is actually throughout one (or sometimes many) composition objects: objects whose sole purpose is to make links between other objects.

struct ProductComments {
  let product: Product
  let comments: [Comment]
}

func getComments(of product: Product) -> Future<ProductComments> {
  return builder
    .query(.comments(of: product))
    .request()
    .map { ProductComments(product: product, comments: $0) }
}

Using a composition object (ProductComments) bring a lot of advantages:

Product and Comment are way simpler. And they can be used without having to refer/deal with the other one (remember the child without its parents).

You avoid having big models with tons of attributes by breaking them into smaller ones. Which otherwise make using them and refactoring them a challenge.

Our relations are flattened. This is not visible here but instead of having dive into hierarchy to find an object (a.b.c...) all our relationships are gonna be only one depth (a.*)

Last but not least relationships are actually expressed. We are using ProductComments because we are interested into the relationship.

It is nothing new. It is coming from Domain Driven Design where those objects are called Aggregate Roots. In future articles I will show how this modeling improve code isolation when combined with other patterns like Smart/Dumb components.