Type and Domain-Driven Design

Switch2 has many service offerings within the Heat Network space, from Design & Build all the way through to Billing the resident. Having so many different services means that processes become complex quickly and terminology can get muddled which can lead to complicated and unstructured code that may be difficult to understand and work within.

To help alleviate these issues we’ve adopted a combination of 2 different design approaches that reduce our complexity and create cohesive understanding; right there in the code!

Domain-Driven Design

Domain-Driven Design is a process which centres on creating a model that accurately represents the rules, bounds, and processes of a domain. This is an incredibly useful tool for making sure that a team understands the intricacies of a domain with a common language that allows smooth communication with all parties.

We implement this process by engaging with the Subject Matter Experts (SMEs) within our business when we take on new areas of work. This is usually as simple as meeting with a few goals:

  • Understand the bounds of a domain - Asking questions like “What does Done look like for this process?” and “What interactions do these processes have with other domains/teams?”
  • Creating a common language - People within the business can use the same term or word to mean different things so nailing down what it means to them and the wider business to create this common language is very important.
  • Understand the rules that are in place for these processes - Understanding the flow of a process is critical to ensuring that work cannot be misrepresented with questions like “Can a piece of work ever jump from Step 1 straight to Step 5?” and “Can it ever go backwards a step?”.

Type-Driven Design

Type-Driven Design is a process which centres on creating the types for your software before producing any code that utilises it. Similar in concept to Test-Driven Design which wants tests first to ensure that the requirements of the code is laid out before work starts. Type-Driven Design really shines in strong statically typed languages like Rust, where the type system will aid you in ensuring correctness. If your business rules are encoded in your types, compilation is proof your program works as expected.

Now Kiss

It should be clear from their definitions how we can combine these 2 approaches to create a development process that ensures that the Domain is both well-described by the code and enforced by the code.

Let’s look at a couple of examples to see how we might do it.

A Meter Reading Example

After discussing with SMEs we discover that a Reading is composed of 2 parts, a value and a unit. We encode this in a Type called Reading which captures these requirements.

Further discussion also reveals that the value property isn’t merely an integer - we shouldn’t be able to do normal mathematical operations to these values as they’re generally static. By encoding this in a newtype called ReadingValue we can define the operations that are allowed on this type making invalid states unrepresentable - as it stands in the example here, you cannot accidentally divide two readings together, for example.

struct Reading {
  value: ReadingValue,
  unit: ReadingUnit,
}

struct ReadingValue(i64);

enum ReadingUnit {
  KWh,
  Ltr,
}

It then transpires that ReadingValue should always be non-negative. Until Rust adds refinement-types, we can encode this invariant with a combination of newtype and smart constructor:

struct ReadingValue(NonNegative);

struct NonNegative(i64);

impl NonNegative {
  fn new(d: i64) -> Option<Self> {
    if d < 0 {
      return None;
    }
    Some(NonNegative(d))
  }
}

Now ReadingValue contains a non-negative value, enforced at construction-time for the NonNegative type. Any function that takes ReadingValue is guaranteed to be taking a non-negative value, satisfying our invariant.

fn display_reading(reading: Reading) {
  // reading.value is guaranteed to be non-negative here
}

An Estimated Meter Example

In the world of utilities, meter reads fall into two categories: estimated readings, where we cannot get a real reading, and actual readings. Suppose our SME comes to us saying that the business needs estimated readings to be sent into our data engineering department to fine-tune our AI models. How do we make sure we don’t accidentally send actual readings?

Using newtypes we can wrap Reading to create two new variants.

struct ActualReading(Reading);

struct EstimatedReading(Reading);

Now we can enforce at compile-time that all readings that are sent into the data-warehouse are estimated readings by creating this function.

fn send_reading_to_datawarehouse(reading: EstimatedReading)

It is impossible, enforced by the compiler, to send an actual reading. Invalid states made unrepresentable. And these types can be written whilst in the room with the SME. They are simple-enough anyone in the room can understand them and they allow us to encode invariants with our business partners when the communication cost is lowest.

Benefits

  • Changes in domain requirements are reflected in types which makes refactoring safer and more predictable, especially when coupled with Rust’s type system and compiler.
  • Types encode domain rules, so many errors are caught at compile time rather than runtime.
  • New team members can learn the domain by reading the code, as types and structures mirror real-world concepts.

Trade-offs

  • Requires more time and effort early in the project to model the domain and define types.
  • Overly strict types can make rapid prototyping harder; flexibility may be reduced.
  • For simple domains, the extra abstraction may be unnecessary and add cognitive overhead.

AI

In a world where AI can write a lot of code for us it is becoming increasingly more important that it writes correct code. Any strongly-typed language system is at an advantage here as it won’t compile without being correct. No hidden errors that blow up at runtime or even worse, don’t.

This framework allows us to create the foundation for the AI to build upon. Encoding valid and invalid states before the AI gets started means that any code it generates has a great foundation for being correct. You still have to watch out for it changing the base types to allow invalid states to be represented but with a good set of instructions the AI can perform well here.

Conclusion

Combining Domain-Driven Design with Type-Driven Design creates a powerful framework for building robust, maintainable, and understandable software. By encoding domain knowledge directly into types, we ensure that our codebase reflects real-world rules and prevents invalid states, making it easier for teams to collaborate and adapt to change. This approach not only reduces bugs and improves onboarding but also provides a solid foundation for leveraging AI tools safely and effectively. While it requires upfront investment and discipline, the long-term gains in clarity, correctness, and adaptability make it a compelling methodology for complex domains.