DIP with Go Interface

Read first: The "D" in SOLID

As a follow up post, we will see how we actually structure the code end to end to conform to this DIP principle. We use Go as an example but principles remain the same in other languages.

Like other OOP Go supports interface, however it's implicit.

Interfaces in Go provide a way to specify the behavior of an object: if something can do this, then it can be used here.

So an interface just defines the 'contract': a set of method signatures. Any concrete structs (class) can be used as long as it satisfies all methods. (same method name and input/output types) . We don't need to explicitly specify which interface it implements. We will see how this could help in terms of DIP.

Let's continue with the conceived user checkout flow in an online shopping scenario. Every time a request coming in from the client app, we usually need an API that serves the request:

  • Use some HTTP routing lib to run handler code

  • Handler invokes domain service (checkout) to do all the core logic

  • Checkout service performs all external communications, e.g db ops / issue requests to external payment gateway etc.

Let's break down to the code structure with DIP in mind.

Define the contracts domain services depend on

To achieve 'inversion', we need to train our mind to first think from contracts perspective.

So for checkout scenario, imagine user finalises the process with payment, a request coming to the API handler, the main execution flow could look like below:

  • Check the product inventory repo

  • Issue payment request to external gateway

  • Update order status as paid

  • Depend on biz requirement, there could be more e.g add rewards points.

Let's see how the contracts look like.

// these defined in domain layer
package bizdomain

type ProductRepo interface {
  CheckAvailability([]entities.Product) (bool, error)
}

type OrderRepo interface {
  UpdateStatus(ordeId int64) (entities.Order, error)
}

type PaymentProvider interface {
  Charge(entities.Order) (error)
}

// domain service only knows the contracts (declared by interface)
type CheckoutService struct {
  productRepo  ProductRepo
  orderRepo    OrderRepo
  paymentProvder PaymentProvider
}
func NewCheckout(p ProductRepo , o OrderRepo, payment PaymentProvider) *CheckoutService {
  return &CheckOutService{p, o, payment}
}

// sudo code to illustrate logic
func (t *CheckoutService) DoCheckout(order entities.Order) error {
  valid := t.productRepo.CheckAvailability(order.Products)
  if !valid {
    return errors.New()
  }
  err := t.paymentProvider.Charge(order)
  if err != nil {
    return err
  }
  order := t.orderRepo.UpdateStatus(order.Id)
  if order.Total > 100 {
    // more logic, e.g reward user with points 
  }
}

Also we need to define entities in this layer. This domain entities are pure data model to represent domain objects, which are agnostic to external infra or data storage.

package entities

type Product struct {
  Id int64
  Name string
}

type Order struct {}

Implementation in Repo/Infra layer

package productrepo

import "your-service/bizdomain/entities"

type productDBRepo struct {
  db *gorm.DB // we can use other libs
}
func New(db *gorm.DB) *productDBRepo {
  return &productDBRepo(db)
}
// By implementing this method it satisfies ProductRepo
// can be used where ProductRepo appears
// do not need to specify it implements ProductRepo
func(r *productDBRepo) CheckAvailability([]entities.Product) (bool, error) {
  //
}

Same in other repositories and payment implementation which deal with all external communication.

package orderrepo

import "your-service/bizdomain/entities"

type orderDBRepo struct {
  db *gorm.DB
}
func NewOrderRepo(db *gorm.DB) *orderDBRepo {
  return &productDBRepo(db)
}
func(r *orderDBRepo) UpdateStatus(orderId int64) (entities.Order, error) {
  // code 
}

Assemble dependencies in the application handler

Now we are at application layer, usually we have API handler, here is where everything is set up. It instantiates the checkout service with needed dependencies injected (these concrete structs).

package api

// bootstrap the service with needed dependencies and attach api handler
func NewRouter() {
  // real db connections, runtime config are used to instantiate concrete
  productRepo := productrepo.New(db)
  orderRepo := orderrepo.New(db)
  paymentProvider := payment.NewProvider(config)
  // See how we can inject concrete DB repos in runtime
  // as they conform to interfaces from domain layer
  svc := bizdomain.NewCheckout(productRepo, orderRepo, paymentProvider)

  router := gin.New()
  router.POST("/checkout", svc.DoCheckout)
}

DIP == Great testability

With dependencies abstracted, it's easy to write unit tests in isolation for CheckoutService which is the core of our application without worrying about external details. We just need mocked objects which implement these interfaces, i.e same method signature.

It runs fast and can easily simulate different data access result /payment processing scenarios, which otherwise could be time consuming to prepare the data in real storage. We can have another write-up to write effective unit tests.

It also resonates with other two principles

  • Open/Closed Principle (OCP)

  • Liskov Substitution Principle (LSP)

With this structure, we can easily swap or add another payment gateway as long as they conform to Charge signature.

Closing Thoughts

Let's do some recap see if all these initial dry DIP definition start making more sense now.

High level modules (CheckoutService) do not depend on low level, but on abstractions.

If we look at how we define CheckoutService

// All 3 dependencies are defined via interface 
type CheckoutService struct {
  productRepo ProductRepo
  orderRepo   OrderRepo
  paymentProvder PaymentProvider
}

All 3 dependencies are expressed as interface, it does not know anything about how product should be checked or payment should be processed. If we see StripeClient or DB connection, that's a sign that we're not inverting, but following runtime execution to structure the code, which is kind of straightforward, but expose tight coupling hence make testing hard to write.

Abstractions should not depend on details, details should depend on it.

If we look into these ProductRepo, OrderRepo or PaymentProvider, all input/output are either Go built in types or can be complex domain entities, but they are all agnostic to infra details. We can see there is no specific db types returned from Repository, or input parameters to PaymentProvider is just pure order entity Charge(entities.Order) (error)

It's inside implementation they know what data types domain needs. They are responsible for mapping between domain and specific data types from external. e.g DB data model to entity or order entity to the exact request shape required by external gateway.

Further Read

Practical SOLID DIP

Dependency Injection (DI) and Inversion of Control (IoC)

Common Anti Patterns

Go unit testing-mocking