Structuring Portable Code

The Go CDK’s APIs are intentionally structured to make it easier to separate your application’s core logic from the details of the services it is using.

Motivation🔗

Consider the uploader tutorial. Without the Go CDK, we would have had to write a code path for Amazon’s Simple Storage Service (S3) and another code path for Google Cloud Storage (GCS). That would work, but it would be tedious. We would have to learn the semantics of uploading files to both blob storage services. Even worse, we would have two code paths that effectively do the same thing, but would have to be maintained separately. It would be much nicer if we could write the upload logic once and reuse it across providers. That’s exactly the kind of separation of concerns that the Go CDK makes possible.

(More details available in the Go CDK design doc.)

Portable Types and Drivers🔗

The portable APIs that the Go CDK exports (like blob.Bucket or runtimevar.Variable) are concrete types, not interfaces. To understand why, imagine if we used a plain interface:

Diagram showing user code depending on blob.Bucket, which is implemented by awsblob.Bucket.

Consider the Bucket.NewWriter method, which infers the content type of the blob based on the first bytes written to it. If blob.Bucket was an interface, each implementation of blob.Bucket would have to replicate this behavior precisely. This does not scale: conformance tests would be needed to ensure that each interface method actually behaves in the way that the docs describe. This makes the interfaces hard to implement, which runs counter to the goals of the project.

Instead, we follow the example of database/sql and separate out the implementation-agnostic logic from the interface. The implementation-agnostic logic-containing concrete type is the portable type. We call the interface the driver. Visually, it looks like this:

Diagram showing user code depending on blob.Bucket, which holds a driver.Bucket implemented by awsblob.Bucket.

This has a number of benefits:

  • The portable type can perform higher level logic without making the interface complex to implement. In the blob example, the portable type’s NewWriter method can do the content type detection and then pass the final result to the driver type.
  • Methods can be added to the portable type without breaking compatibility. Contrast with adding methods to an interface, which is a breaking change.
  • When new operations on the driver are added as new optional interfaces, the portable type can hide the need for type-assertions from the user.

(More details available in the Go CDK design doc.)

Best Practices🔗

  • Create portable types as close to program startup as possible. Since creation of a portable type requires using driver-specific setup, this separates your driver-specific details from the rest of your application.
  • Pass portable types around as arguments or struct fields instead of as package variables. This allows you to easily swap out the portable type for a local implementation in unit tests. It also enables you to use dependency injection tools like Wire to set up your application.
  • Avoid using As functions when possible. Using driver-specific options makes it harder to test your code with confidence or migrate to another driver later. If your application needs to use driver-specific options, try to make it so that other drivers fall back gracefully. For example, you may need to use a particular ACL setting for a write to a Google Cloud Storage bucket. When testing for the driver-specific write options, don’t return an error if the As function doesn’t have the right type. That way, when running against an in-memory bucket for tests, the write will still occur and can be observed. Leave provider-specific checks to integration tests.