jeudi 15 août 2019

Design where interface implementations could create circular dependencies?

I'm in the process of designing a library that helps us to deal with the context in which applications are ran and can resolve various context-related information such as the base application's URL (AppUrlProvider), the environment (EnvironmentProvider), the configuration (ConfigProvider), etc.

The library has the following requirements:

  1. Provide default provider implementations that doesn't have to be explicitly instantiated
  2. Providers can be expensive to call, cache their results
  3. Allow to swap provider implementations
  4. Make it unlikely that a concrete provider's developer will inject another providers implementation in the constructor, potentially leading to many distinct providers of the same type being used.
  5. Deal with the fact that certain providers configurations could lead to cyclic dependencies.

I'm planning on solving #1, #2 and #3 using an AppContextProvider class that would be used as such:

/*
This code could run multiple times per application lifecycle
*/
context = AppContextProvider.getOrCreate(providers => {
    //This block of code runs only once per application lifecycle

    providers.useEnvironmentProvider(new TextFileEnvProvider('environment.txt'));
});

I'm planning on solving #4 by providing the AppContext (which wraps over all providers and have all lazy properties) instance to the getter methods of the providers. All values provided by providers ends up in the AppContext. E.g. environment property comes from EnvironmentProvider, etc. Therefore, if a ConfigurationProvider needs access to the environment name he'd just do appContext.environment() which will in turn lazily call environmentProvider.environment(appContext);.

e.g.

class AppContext {
    Lazy<Environment> lazyEnvironment = () => environmentProvider.environment(this);
    Lazy<Config> lazyConfig = () => configProvider.config(this);

    Environment environment() {
        return lazyEnvironment.get();
    }

    //...etc
}

class EnvBasedJsonFileConfigProvider implements ConfigProvider {
    //... 
    Config config(appContext) {
        env = appContext.environment();

        filePath = appContext.appPath().resolve(env + 'config.json');
        //...            

        //PROBLEM: calling appContext.config() would lead to infinite loop here
        //not sure what would be a clean design to avoid that?
    }
}

Now regarding problem #5 which is a variant of the PROBLEM comment above, I'm not so sure how to make the design explicit so that cyclic dependencies between providers don't become a nuisance.

E.g.

//Assume default ConfigProvider calls appContext.environment()
context = AppContextProvider.getOrCreate(providers => {

    providers.useEnvironmentProvider(new ConfigBasedEnvironmentProvider());
});

class ConfigBasedEnvironmentProvider implements EnvironmentProvider {

   string environment(appContext) {
       //PROBLEM: This will lead to an infinite loop if the
       //ConfigProvider calls appContext.environment()...
       return appContext.config().get('environmentName');
   } 
}

That's it! I'm puzzled as to how such system should be designed to mitigate the risks of cyclic dependencies while preserving the flexibility of having providers leverage values coming from other providers. It doesn't feel right that the system would break when a certain combination of providers is used.

One approach I'm considering is to try to work out what values could each provider kinds could need from the application context and limit the value each providers have access to. For instance, it's way more likely that the ConfigProvider needs the know the environment than the other way around, so I could design the interfaces as such:

//It's clear we depend on environment
interface ConfigProvider {
    Config config(environment, appPath)
}

interface EnvironmentProvider {
    string environment(appPath);
}

But then these interfaces are kind of leaking implementation details. An EnvironmentProvider reading from a text file would need the appPath, but one reading from environment variables or a database doesn't care about the appPath at all so that feels like a design smell.

Anyway, I'm looking for suggestions to circumvent these problems, thanks!

Aucun commentaire:

Enregistrer un commentaire