Go – Same interface to handle multiple types

Issue

I am dealing with multiple vendor APIs which allow creating Device records but as expected they represent devices differently. Basic example (focusing on difference of ID types among vendors) –

Vendor1#Device uses integer IDs { ID: <int>, ...vendor1 specific details }

Vendor2#Device uses UUIDs { UUID: <string>, ...vendor2 specific details }

Since, the structures vary among vendors, I am planning to save these (device records) in a MongoDB collection so I have created the following interface to use from application code –

type Device struct {
  Checksum string
  RemoteID ?? # vendor1 uses int and vendor2 uses uuid
}

type DataAccessor interface {
    FindDeviceByChecksum(string) (Device, error)
    InsertDevice(Device) (bool, error)
}

This will be used from a orchestration/service object, like –

type Adapter interface {
    AssignGroupToDevice(GroupID, DeviceRemoteID ??) (bool, error)
}

type Orchestrator struct {
    da             DataAccessor
    vendorAPI      Adapter
}

// Inside orchestrator#Assign method
device, _ := o.da.FindDeviceByChecksum("checksum from arg")
.
.
o.vendorAPI.AssignGroupToDevice("groupID from arg", device.RemoteID ??)
// The above method calls out vendor's HTTP API and passes the json payload built from args
//

As you can see, I can’t put a type for RemoteID or DeviceRemoteID. What are my options to handle this pragmatically? An empty interface would have me writing type switches in the interface implementation? Generics? or something else? I am confused.

Solution

Your application code should not care at all about the actual vendors and their APIs.

Try to define some core entity package that you will use in your domain, in your application. This can be anything you decide and shouldn’t be dependent on external dependencies.

The service will define the interface it needs in order to do the appropiate BL (Find, Insert, Assign group id)

For example:

Entity package can be device

package device

type Device struct {
  Checksum string
  RemoteID string
}

Note that you can define the RemoteID as a string. For each vendor, you will have an adapter that has knowledge of both the application entities and the external vendor API. Each adapter will need to implement the interface the service requires.

type DeviceRepository interface {
    FindDeviceByChecksum(string) (device.Device, error)
    InsertDevice(device.Device) (bool, error)
}

type VendorAdapter interface {
    AssignGroupToDevice(GroupID, DeviceRemoteID string) (bool, error)
}

type Orchestrator struct {
    deviceRepo    DeviceRepository
    vendorAdapter VendorAdapter
}

// Inside orchestrator#Assign method
device, err := o.deviceRepo.FindDeviceByChecksum("checksum from arg")
if err != nil {...}
.
.
o.vendorAdapter.AssignGroupToDevice("groupID from arg", device.RemoteID)
//

You can note here a few things:

  • Defined the interfaces in the service package. (Define interfaces where you are using/requiring them)
  • DeviceRepository: This is the data layer, responsible for persisting your entity (into mongo) (repo is just a convention I’m used to, it doesn’t have to be called repo 🙂
  • VendorAdapter: adapter to the actual vendor. The service has no knowledge about the implementation of this vendor adapter. It doesn’t care what it does with the remote-id. The vendor API that uses int will just convert the string to int.

Of course naming is optional. You can use DeviceProvider instead of VendoreAdapter for example, anything that will make sense to you and your team.

This is the whole point of the adapters, they are converting from/to entity to the external. From application language into the specific external language and vice versa. In some way, a repository is just an adapter to the database.

enter image description here

Edit: For example, the vendor adapter with the int remote-id will just convert the string to int (Unless I’m totally missing the point lol:) :

package vendor2

type adapter struct {
  ...
}

func (a *adapter) AssignGroupToDevice(groupID, deviceRemoteID string) error {
    vendor2RemoteID, err := strconv.Atoi(deviceRemoteID)
    if err != nil {...}

    // send to vendor api2, using the vendor2RemoteID which is int
}

Edit2:
If there is another field that is different between these vendors and is not primitive, you can still use string. The vendor’s adapter will just need to marshal the string to the specific vendor’s custom payload.

Another way is of course do as @blackgreen said and save it as interface{}

You need to make a few decisions:

  • How you will serialize it to the database
  • How you will serialize it in the vendor’s adapters
  • Are you using it in the application? Meaning is the application has knowledge or is agnostic to the value. (if so, you probably won’t want to save it as a string. maybe 🙂

The repo – will save it as JSON to the DB

The vendor adapter – will convert this interface{} to the actual payload the API needs

But there are many many other options to deal with dynamic types, so sorry I didn’t quite answer your question. The core of the solution depends on whether the application uses it, or it is only data for the vendor adapter to use.

Answered By – oren

Answer Checked By – Candace Johnson (GoLangFix Volunteer)

Leave a Reply

Your email address will not be published.