A repository/store for database as interface or per table interface?

Viewed 56

What I designed first was to have a Store interface as follow:

// store.go
type Store interface {
    CreateUser(user model.User) (string, error)
    GetProfile(userId string) (model.User, error)    
    CreateHouse(user model.House) (string, error)    
}

And in another file, mongo_store.go, its implementation codes:

type mongoStore struct {
    store *mongo.Client
}

func (mc *mongoUserStore) CreateUser(user model.User) (string, error) {

}

// And so on...

In mongo_store.go I have another method that returns an instance of MongoStore:

func NewMongoDBStore() Store {
    // Some code to connect to MongoDB and finally
    s := &mongoStore{
        store: client,
    }
    return s
}

I've gone this way to abstract away DB layer. So in code we pass store around and call let's say CreateUser as an example.

My team members had the object of creating Store interface per table. So we should have UserStore interface with their methods or HouseStore with their own methods.

First question is that is this a best practice to change the code this way? I could not come up with a good argument to reject their change request. It's been said that this way we can mock less code in tests and also it is not polluted, all in one place for all methods that work with DB.

My Second Question is if we go the second approach, how NewMongoDBStore should return different store types. So instead of Store as return type we have to have different store types like UserStore, HouseStore, etc.

1 Answers

I always try to stick to one rule when designing new interfaces in Go: keep interfaces as small as possible. You can see that stdlib also tries to follow that rule, see for example fmt.Stringer, http.Handler or json.Marshaler. Look how in the json library they even separated json.Marshaler and json.Unmarshaler (same for the io.Reader and io.Writer), which, you can say, seem to be very connected together.

Coming back to your example, I think that your team makes a good point - I would go for the separation of the storages interfaces. The only situation in which I wouldn't do that is if you are sure that this interface will never expand and will always stick to this very limited number of methods. But I think this is very unlikely for the storage-like interfaces. For example in the near future you could like to add some more-grained filtering methods, or e.g. a method to insert storage objects in a batch.

In my opinion you can only benefit from separating the interfaces and here is why:

  1. It's true that it is easier to mock an interface with a 1-2 methods than an interface with, let's say, 10 methods.
  2. It's always better to separate functionalities into smaller pieces as you may not need to use all of them at once in every place. To give you a better picture you can have one service which would use your UserStore and your HouseStore implementations, but you can also have a second service that wouldn't need a HouseStore and would only use a UserStore implementation. Thanks to that it would be much easier to mock the second service (as it uses only a UserStore) and if you later add any methods to the HouseStore there is no possible way it could affect the second service anyhow as it knows nothing about this interface.

I think the above answers your first question. Coming to the second question you can solve it in two ways I think:

  1. First way is something I usually do. You can simply create separate implementations for separate interfaces. So if you have, following your example, a file store.go containing interfaces:
type UserStore interface {
    CreateUser(user model.User) (string, error)
    // Rest of the methods ...
}

type HouseStore interface {
    CreateHouse(house model.House) (string, error)
    // Rest of the methods ...
}

I would make a user_mongo_store.go with MongoDB implementation for the UserStore ...

type userMongoStore struct {
    store *mongo.Client
}

func (s *userMongoStore) CreateUser(user model.User) (string, error) {
    // CreateUser method implementation ...
}

func NewUserMongoStore() UserStore {
    // Some code to connect to MongoDB and finally
    s := &userMongoStore{
        store: client,
    }

    return s
}

// Rest of the UserStore methods implementations ...

... and I would also make a house_mongo_store.go file with MongoDB implementation for the HouseStore:

type houseMongoStore struct {
    store *mongo.Client
}

func (s *houseMongoStore) CreateHouse(house model.House) (string, error) {
    // CreateHouse method implementation ...
}

func NewHouseMongoStore() HouseStore {
    // Some code to connect to MongoDB and finally
    s := &houseMongoStore{
        store: client,
    }

    return s
}

// Rest of the HouseStore methods implementations ...

You could ask here if will not feel inconvinient to keep two MongoDB storages implementations separated as they could contain the same MongoDB-related operations. Answer to that question is no: you can always create e.g. mongo_store.go to keep all the common functions that will be shared by all the MongoDB storages implementations.

The only disadvantage I can see here is a little bit more code in general, but in the end it gives you much cleaner, better separated and more modular code.

  1. Second way, which I would recommend less, is to use the (in my opinion) very powerful Go feature which is a fact that you don't declare implementing an interface (unlike in e.g. Java), you just have to implement all the interfaces methods in your struct and you can use it as all these interfaces implementations. In your case you could stick to the single mongoStore struct and make it implement both the UserStore and the HouseStore interfaces methods. That way you would end up with something like this:
type mongoStore struct {
    store *mongo.Client
}

func (s *mongoStore) CreateUser(user model.User) (string, error) {
    // CreateUser method implementation ...
}

func (s *mongoStore) CreateHouse(house model.House) (string, error) {
    // CreateHouse method implementation ...
}

// Rest of the UserStore and HouseStore
// interfaces methods implementations ...

but this solution leaves us with a problem: how to create a function to create UserStore and HouseStore interfaces implementations. Well, in this situation you could either make mongoStore struct exported and use it directly as both a UserStore and HouseStore implementations or, which looks a little bit more exotic but is still a valid piece of code, you could make a function that would return this single struct as both implementations, e.g.:

func NewMongoStores() (UserStore, HouseStore) {
    s := &mongoStore{
        store: client,
    }
    
    return s, s
}

I think I gave you some options, but to sum up, I would encourage you to keep your interfaces and their implementations separated.

Related