Lonestar ElixirConf 2017 - KEYNOTE: Phoenix 1.3 by Chris McCord

Sure, I think perhaps my wall of text obscured the intent of the question. I believe what you’re referring to is the practice of copying down the details of a product at the time of sale, so that when viewing an order you can see what was bought at that time even if the actual Product being referenced has changed or no longer exists. FWIW I tend to dump this kind of denormalised data into jsonb columns.

There were two parts to my questions and two parts to your answer, so I’ll provide a context and then continue with the two parts of the conversation.

The situation

Assume now that we run a company supplying jellybean vending machines to shopping centres and we have two contexts in our app: Sales and Support.

The Sales context deals with our web store where we list our many offerings, take orders, process payments, track shipping to our customers and so on. We, of course, have a Product entity and a Customer entity within that context. A Product will hold information about the name, price, image, description, while a Customer will hold information about a particular customer’s name, address, email, contact name, shipping address and so on. When we create an Order we copy in the relevant Product information (as well as storing a foreign key too), and store a reference/foreign key to the Customer who made the order.

We also have a support portal section of the site where people not happy with our service, or who encounter any problems can go and vent to our poor customer service people through the creation of Tickets. Our employees have special access to this panel where they can view all the tickets, manage them, issue refunds, respond to queries. Note that these tasks will need to access different contexts (tickets vs refunds—Support vs Sales) but that’s ok, since we’ve decoupled our controllers from the business logic layer! :tada:

Now, in this Support context we clearly need Customers, since they are the ones creating tickets. This Support.Customer is different from the Sales.Customer though—we don’t need to care about their payment information, or shipping address, but in turn we might care about whether they are a VIP support client and we need to get to them right away, or we simply want to model that a Support.Customer has_many Tickets. This is still fine and in keeping with the spirit of the Bounded Context principle.

We also need Support.Products—again we probably don’t care about stock quantities, or pricing, but we may care about special instructions for supporting certain products, internal links to scripts or the specific set of instructions to walk a customer through getting into the back control panel of the BeanTron 3000—again, things which are relevant to us in the Support context.

Cross-context entities at the data layer

This related to my original question. Chris made the point that you want to have two data structures, one per context, even if they “represent” the same thing, as their respective views of that thing may differ. Then, Michał made the point that Ecto allows us to define multiple schemas on the same DB table, which allows us to consolidate the storage of these different fields at the DB layer, while keeping them separate at the business logic layer. All good so far.

This is, in fact, the case with our Customers. Support.Customer and Sales.Customer are two distinct data structures in our code, but conceptually, both represent the same customer—one could say they are two views of the same data. Note however, that they will have shared fields such as name or email. We want to keep these fields consistent between the two views, and we want to preserve the conceptual identity of the customer—for example, if we delete one of these Customers, the other one gets deleted too.

Now, we can take the following approaches to storing things in the database:

  • Have two separate tables, `support_customers` and `sales_customers`, perhaps with one of them being the "canonical" or main one, storing all of the shared data as well as its context-specific data. Let's say it's the `sales_customers` table (because that was initially the only table, and we only added the support portal after our phone line started being busy 24/7).
    

    The Support context, when asked to fetch a customer, actually asks the Sales context for a customer, and then strips away irrelevant fields and adds support-relevant ones, populated with data from the support_customers table looked up by having a sales_customer_id foreign key in that table.

    We need to ensure that the Ecto schemas for the two structures are the same regarding the shared fields like name and email, and we need to ensure that when one gets deleted, so does the other (yay for DB constraints/cascades).

  • Have three tables, customers, sales_customer and support_customers, add a new context Customers, and have our Sales.Customers and Support.Customers be created by fetching the “canonical” customer through the bounded API and then embellishing it with relevant fields from our own table. We’re going for full normalisation here, and our invariants can be enforced with DB constraints, but it seems like overkill. Again we end up creating extra contexts for anything which is shared.

  • Have one table, customers, and have Sales.Customer and Support.Customer both be an Ecto schema on the same table. This is what I believe Michał was talking about. Again we need to ensure that our shared fields are the same in the schema. But we will likely want to have shared validation logic for our shared fields for our changesets, so where does that go now?

Which one is best, or is there another, better way?

Context-switching entities

Suppose we are viewing a ticket in our support system. We have our Support.Customer Bob, the author of the ticket, loaded available in our controller and we are displaying his name and other tickets in the sidebar. But now our support staff tell us it would really be helpful if they had a list of Bob’s recent orders in the sidebar too. To get that, we’ll need the Sales.Customer representing Bob. How do we go about it? If we were using the “one table” method above, we could simply write

sales_customer = Sales.get_customer_by_id(support_customer.id)

but now we’ve coupled our controller to our database layer! If we change things around so that the two views of a customer no longer share an id, suddenly we are showing Jane’s orders in the sidebar and the customer support people will hate us even more.

What I propose is better, and what my question was about, is to be able to write

sales_customer = Sales.customer_from_support_customer(support_customer)

where this method will do the right thing to fetch me a different view of the same customer. If we have entities which cross contexts in our domain, they are coupled in the domain, and so I propose it’s impossible to avoid coupling in our business logic model. What we can and should do is to push that into a small codebase at the points where the two contexts interface, so that if we did change how we represent customers at the data layer, we would only need to change a few access and conversion functions in the two context modules.

Cross-context associations

This was the second part of the question. I see your point that associations should not go across contexts, because strong associations between entities usually imply they belong in the same context. What I’m referring to are “loose associations” I suppose. Let me demonstrate.

Let’s suppose that we want to be able to associate Tickets with a specific order, to ease things like billing or shipping enquiries. That is, we want to associate a Sales.Order to a Support.Ticket—a cross-context association! Now, our Support context isn’t really going to do any processing on this order, that’s not its job. All it’s really going to do is just give us the order so we can use the functions in the Sales context to process it appropriately.

The question is in relation to actual Ecto schemas now. Is it ok to do this?

schema "tickets" do
  ...
  belongs_to :order, Sales.Order
  ...
end

Now we have signed up to validating this linked order in changesets as well I suppose, though this could delegate out to the appropriate Sales functions.

One idea which comes to mind is to just do this:

schema "tickets" do
  ....
  field :order_id, :id
  ...
end

that way, we store the relevant information, but we don’t pretend to know anything about its domain.

— Hey, Support, what’s the date of the order associated with this ticket?
— I don’t know what orders are, or if they have dates, or where they’re stored, but hey, here’s this arbitrary id that Sales gave me to hold on to, why don’t you go ask her about it?
— Oh ok, thanks!

Thoughts?

1 Like