I'm looking for a C#/OOP architecture/pattern that would allow class A to initialize a request to do an action which would then be handled by class B or C depending on the build, with class A having absolutely no knowledge whatsoever of the existence of class B/C.
There are a handful of ways to avoid tight coupling the way you're describing.
The first that comes to mind is using interfaces. C# supports creating an interface
as a specific feature. If you create an interface with a method representing your action, then class A can call the method on any object implementing that interface. Class B and C can implement that interface, and class A doesn't need to know whether the object it's given is an instance of class B, C, or any other implementation.
The tricky part is that you need to determine how to give A
access to an object implementing the interface. The best approach will be based on your specific situation, but typically this is done by passing it as either a parameter to the method in A
that needs to call the method, or by passing it into the constructor of A
, so A
can capture it as a field and use it later. These techniques are known as Dependency Injection broadly (DI for short), and Method Injection or Constructor Injection specifically.
If you use Dependency Injection as a pattern broadly, it can help to use a library to help you register (on startup, for example), which classes you want to use when implementing various interfaces, and how you want to manage their lifecycle (e.g. should the same instance of your container management object be used throughout your application, or is it okay to create new instances on demand as long as they exhibit the same behavior?). The library can then be responsible for creating your main "root" class based on this information. These libraries are known as DI "Containers".
Events are also an option, as you noted. If classes B or C know all about any instances of class A, they could subscribe to the "Transfer Request" events that A emits. Events have the advantage of allowing multiple other classes to subscribe and respond to the same event. However, they are designed for what's called a "publish/subscribe" model (pub/sub for short), and have a couple of shortcomings. For example, it's difficult to have the action that you invoke return a value back to the code that's invoking the event. It also requires that the class responding to the event have access to the object emitting the event. For this reason, events usually bubble up through your application's hierarchy, whereas commands usually flow from the top down. The principles of "Inversion of Control" and "Dependency Inversion" deal with ensuring this top-down flow doesn't create tight coupling between classes, and those terms are sometimes used synonymously with Dependency Injection.
You can combine these approaches in ways that make sense for the architecture of your code. For example:
A
or somebody else that just invoked a transfer request.A
, B
, and C
could all inject that interface. A
would call a method on the interface to create the transfer request, and B
and C
could subscribe to events emitted by that interface.A
could inject your transfer request manager to tell it when a transfer request happens, and the transfer request manager could inject the interface implemented by B
and C
to let the container management code know it needs to do something about the transfer request.