How to use environment variables to switch between sub-packs in packwerk?

This message was imported from the Ruby/Rails Modularity Slack server. Find more info in the import thread.

Message originally sent by slack user U71810IPUMS

Are there any tools in packwerk to better facilitate switching between sub-packs based on environment variables

Can you elaborate on what you mean by switching between sub-packs and what pain you’re trying to alleviate?

Message originally sent by slack user U71810IPUMS

So we have a monorepo where 90% of all code is shared between clients. However, occasionally a specific client wants to override a specific feature so we end up with Standard::Feature and Client::Feature (naming is demonstrative and is completely open to change). When we set the environment variable stating that this Rails instance of the application if for the client, we currently have a unmaintainable mess of code that shims in Client::Feature for Standard::Feature when calling Feature.call . However, since 90% of the code is not changed, they don’t want to recreate all features in the Client pack as that would be hundreds of practically empty files.

Message originally sent by slack user U71810IPUMS

e.g. If both use Standard::CommonFeature we would prefer not the just create a dummy link to Client::CommonFeature just so we could just keep a common namespace.

I’m not sure I’m totally following. You’re saying when the application loads with a certain environment variable, certain methods return one class vs a different one? I’m not sure what you mean by “they don’t want to recreate all features in the client pack.” If the code is shared, why is the client needing to recreate features?

Message originally sent by slack user U71810IPUMS

Yes. One proposed solution would be to “recreate” all class (i.e. Client::CommonFeature = Standard::CommonFeature) so that we could always just call %(#{ENV["CLIENT"]::CommonFeature).constantize and get the right object.

Message originally sent by slack user U71810IPUMS

My hope is that there is a better way

Message originally sent by slack user U71810IPUMS

The current solution is basically to define a hash with exceptions.

  FEATURE_CLASSES = {
    client_a: "ClientA::Feature",
    client_b: "ClientB::Feature",
    default:  "Standard::Feature",
  }.freeze

  Feature = FEATURE_CLASSES[ENV["CLIENT"]].constantize

It sounds like you want to have a common abstract interface for your client. That way you can say that a function always returns an interface, and different things can implement that interface differently. Sorbet makes this pretty easy (link), if you’re already using it

Message originally sent by slack user U71810IPUMS

It is more that we are hoping to have a single public entry point Feature.call and have the private internal API handle the switching in a more maintainable way.

You could still do that with interfaces. I probably wouldn’t use an environment variable for this (although I’m not sure in what context folks are starting and using your application), but either way, you can have the env flag flip between the different implementations (as a hash or case statement or whatever).

Message originally sent by slack user U71810IPUMS

Does your link talk about using interface to do that? We aren’t currently using Sorbet, but anything to fix this issue would be a easy sell. The application is always boot on client servers where that environment variable is never switch, though us as developer change it everytime we switch to a specific clients bug.

Message originally sent by slack user U71810IPUMS

I am not seeing anything about switching between different concrete implementations .

Switching between implementations is totally up to you since it depends on the semantics and logic of switching between them. Sorbet just provides the type support for interfaces.

I guess I’m not understanding what the concrete pain is still. Is the pain that you have to stop and restart your server when switching between implementations?

Message originally sent by slack user U71810IPUMS

We do not need to switch implementations, so that is a relief. The concrete pain is twofold. The first is that it is a common problem that a developer forgets to update a hash . As such, in spite of implementing the client specific version of the code and the unit test for the code as the hashing doesn’t count as a branch, it is frequent place to not add the integration test and the coverage numbers do not reflect it.

Message originally sent by slack user U71810IPUMS

The second is that since some services are used by multiple features, we unfortunately have a windy path where we call Feature.call which delegates to Client::Feature with uses Service which may delegate to Client::Service . This makes the code difficult to follow and adds many unneeded checks.

Sorry I think you lost me again at “a developer forgets to update a hash.” What hash? For what purpose? How is a hash related to client specific implementations of interfaces?

If code is reused, there will always be some abstraction. It sounds like removing some delegates and calling the shared code you want to invoke directly may reduce some indirection.

Overall this sounds like a design problem where the architecture of the system doesn’t lend itself well to the problem you’re trying to solve. Rather than making it easier to work within that painful design it might just be better to work on updating the design to be simpler. It’s hard for me to say though because I’m still trying to understand the problem :pray:

Message originally sent by slack user U71810IPUMS

Sorry, by “forgetting to update the hash”, I am just referring the logic of switching between the concrete classes.