4

Please note: although my question relates to DDD, I am also interested in this from an architectural and OO design perspective. This question may also be a simple case of CQRS and/or interface segregation. Code examples are in Swift as this is the first language that comes to my head right this second, but this is unimportant.

In my domain, I have the concept of a Panorama which is downloaded and displayed to the user. Implementation-wise: Panoramas will be downloaded from the Google street view API, cached in memory, and then stored locally on the device.

I have read that it is fine to hide whatever a repository needs to do to achieve it's goal behind it's public API - it doesn't matter if it's saving / reading from a database, or from an API endpoint.

With this in mind, I created the following interface which will belong in the domain layer:

protocol Panoramas
{
    public function findAll(): [Panorama]
    public function findOne(_ lat: Double, lon: Double)
    public function delete(_ panorama: Panorama)
    public function save(_ panorama: Panorama)
}

My first problem is this: I can't call save() on google's street view API, nor can I call delete(). So my first thought was "read and write models" and interface segregation.

protocol Panoramas
{
    public function findOne(_ lat: Double, lon: Double)
}

protocol QueryablePanoramas: Panoramas
{
    public function findAll(): [Panorama]
}

protocol ModifiablePanoramas: QueryablePanoramas
{
    public function delete(_ panorama: Panorama)
    public function save(_ panorama: Panorama)
}

And I would then create the following concrete implementations in the infrastructure layer:

struct StreetviewApiPanoramaRepository: Panoramas { /** ... **/ }
struct InMemoryPanoramaRepository: ModifiablePanoramas { /** ... **/ }
struct DatabasePanoramaRepository: ModifiablePanoramas { /** ... **/ }

However I think I'm missing something. Panoramas is a domain concern. But Queryable or Modifiable: these are technical to me. Is this a common pattern? Would you name these differently? How can I improve on this?

--

Edit: to make matters worse, the internally stored Panorama is for a file.

struct Panorama {
    public let id: Int // Unique id from the database
    public let filepath: String // The location of the file on-disk
}

So:

  • My database-loading repository loads data via an ORM, and with the file path stored in the database, I need to read the file to return it. So my entity in the database contains a filepath value which requires a second step to read the file into memory.
  • The memory-loading repository just loads the image from memory.
  • The api-loading repository reads the file from an API call.

The issue here is that a repository is supposed to return a valid entity. But in some cases, I need to save the image to a file and then store it, in others I need to load from an API call.

I think I've messed up my data model here. Please help :)

migrated from codereview.stackexchange.com Sep 25 '18 at 10:02

This question came from our site for peer programmer code reviews.

  • Do you have use cases / requirements to motivate the existence of the interface functions that you can't implement with the street view api? – bdsl Sep 25 '18 at 11:36
  • @bdsl Yes, when 'resetting' the application, I'll need to delete everything in persistence. Especially for when the user updates the app, I'll prefer a complete wipe. Save is to save to persistence or to memory, so that has to exist too. findAll will be used to retrieve the objects (a-la-ORM) before deleting them. – James Sep 25 '18 at 12:19
2

Interfaces, protocols, and inheritance in general are not suitable for describing taxonomies of objects. I.e. an interface that describes “A QueryablePanoramas repository is a kind of Panoramas repository that also allows to list all available panoramas” is rarely helpful.

Instead, it is better to use interfaces to describe how some object will be used. If your repositories are consumed by some code that only query the repository and some code that also write to the repository, then splitting those separate concerns into separate interfaces can make sense (this is what the interface segregation principle is about). But here it seems that the consumers need full read/write/query access to the repositories. Then, creating multiple interfaces does not help.

You note the problem that not all data sources are able to support all operations of the repository. But those data sources are not necessarily the same as the repository. Here, it seems that the Street View API can provide some functionalities but needs to be paired with some persistent storage to be fully functional in your application. Then, it would be the job of the repository to make that connection. E.g. (pseudocode b/c I don't know Swift):

struct PanoramaRepository {
  private let storage = ...
  private let streetView = ...

  // first search locally cached panoramas, otherwise fall back to Street View
  public function findOne(lat, lon): Panorama? {
    if (lat, lon) in storage {
      return storage[lat, lon]
    } else {
      return streetView.search(lat, lon)
    }
  }

  // only return the saved panoramaas
  public function findAll(): [Panorama] {
    return storage
  }

  ...
}

Whether this is possible depends on the contract of that repository. E.g. if there is some invariant that the panorama returned by findOne() must be in the list returned by findAll(), this won't work. But when no such restriction exists, the repository can abstract over the specific APIs and storage mechanisms used.

There are a couple of variants to do this, depending on your requirements. For example, you could use the decorator pattern to optionally add Street View support to an existing repository:

struct PanoramasWithStreetView(baseRepository: Panoramas) {
  private let streetView = ...

  // use street view as a fall back if the repository doesn't return a result
  public function findOne(lat, lon): Panorama? {
    if let panorama = baseRepository.findOne(lat, lon) {
      return panorama
    } else {
      return streetView.search(lat, lon)
    }
  }

  // all other methods just delegate to the base repository
  public function findAll(): [Panorama] { return baseRepository.findAll() }
  ...
}

let panoramas: Panoramas = PanoramasWithStreetView(PanoramasInDatabase(...))

This might be more elegant to implement, but adds further flexibility that can make your application more fragile. E.g. you might accidentally use a repository that does not use the street view API.

Finally, consider whether the use of the street view API really belongs into a repository. If this API is not just one data source among many but there is business logic that treats panoramas from the API differently than locally stored panoramas, then extracting the API code into a separate service might be better. The idea of the bounded context might help. To which context do the API calls belong? Is this an implementation detail from the perspective of the core problem domain so that these API calls belong into the “Panorama storage” context?

Depending on your design choices you may also have to reconsider whether your definition of a Panorama is sufficient.

  • If panoramas can come from multiple sources it is difficult to specify a particular ID. Either the IDs should have a namespace (like URIs), merely describe the (unmodifiable!) content (a hash like in blockchains, Git, or IPFS), or ensure uniqueness by combining namespace, timestamp, randomness, and local ID components (like UUIDs).
  • Consider whether a Panorama represents an image that can be displayed directly, a locally saved image, or a more abstract image locator (like an URL). When using a filepath, consider whether this file path is persistent or merely a temporary cache.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.