I came to the conclusion that in this case code cannot be entirely uncoupled. There’s a coupling between business logic and repository implementation by definition of the task at hand.
However, to simplify further code maintenance, I’ve ended up using the following architecture (in pseudo-code):
Core interfaces:
// Main repository interface
interface OrdersRepositoryInterface {
store(order: Order): bool;
// …other methods
}
// An interface of a factory that will be used to create repository instances with different configurations (different underlying storages, for example)
interface OrdersRepositoryFactoryInterface {
createRepository(configuration: OrdersRepositoryConfigurationInterface): OrdersRepositoryInterface;
}
// An interface of a container that will create different instances of repositories based on specified brand
// An implementation of this interface is the coupling point between business logic and data persistence logic
interface OrdersRepositoryContainerInterface {
get(brand: Brand): OrdersRepositoryInterface;
}
Repository factory implementation (tightly coupled to repository itself, can be avoided if repository constructor was specified in interface itself, but I personally consider that bad practice):
class OrdersRepositoryImplementation implements OrdersRepositoryInterface {
constructor(configuration: OrdersRepositoryConfigurationInterface) {
// …
}
store(order: Order): bool {
// …
}
}
class OrdersRepositoryFactory implements OrdersRepositoryFactoryInterface {
createRepository(configuration: OrdersRepositoryConfigurationInterface): OrdersRepositoryInterface {
return new OrdersRepositoryImplementation(configuration);
}
}
Repository container implementation:
class OrdersRepositoryContainer implements OrdersRepositoryContainerInterface {
get(brand: Brand): OrdersRepositoryInterface {
var factory = IoC.resolve(OrdersRepositoryFactoryInterface);
var configuration1 = …;
var configuration2 = …;
if (brand.slug === "brand1") {
return factory.createRepository(configuration1);
} else {
return factory.createRepository(configuration2);
}
}
}
IoC-container binding (if container supports it, it’s probably better to bind classes rather than instances, since automatic Dependency Injection may be used in those implementations’ constructors):
IoC.bindInstance(OrdersRepositoryFactoryInterface, new OrdersRepositoryFactory());
IoC.bindInstance(OrdersRepositoryContainerInterface, new OrdersRepositoryContainer());
And last, but not least, order processing code:
var order = …;
var repositoryContainer = IoC.resolve(OrdersRepositoryContainerInterface);
var repository = repositoryContainer.get(order.brand);
repository.store(order);
This architecture will allow easy replacement of repository resolution logic. For example, repositories for all brands may be unified in the future, in which case only OrderRepositoryContainerInterface implementation will have to be replaced.
But still, OrdersRepositoryContainer is still coupled to repositories’ implementation, since it has to know how and where to get their configuration.
I’ll mark this answer as accepted, but I’ll willingly change that if anyone suggests a better idea.