-1

What is the "best way" to implement something like this:

I have a list of steps that will be iterated and ran for an IContext instance. But, some steps will only apply to a specific instance derived from IContext, while other steps can apply to any instance implementing IContext.

I could create an abstract class ContextStep<TContext> : IStep that will implement the interface and do a type check and downcast the IContext to the generic type in Run(IContext), but I feel like there a better ways of doing this and I am describing an existing pattern.

Why: I want to be able to do stuff specific to a context in a step, e.g. on a DoorContext I want to call Close(), which is specific to the DoorContext.

Pseudo code

interface IStep
{
      void Run(IContext context);
}

IContext context = new DoorContext();

// Steps is a collection of steps, of which some should 
// only be ran for a specific context implementation.
foreach (var step in steps)
{
     step.Run(context);
}

Context

My program is able to analyze two types of applications. .NET Framework (FrameworkContext) and .NET Core (CoreContext). Each application has a totally different runtime behavior, therefore there are two contexts that contain information about the application: configuration, targeted runtime, etc., however, none of these are important details.

I have two use cases for this question in my application:

  • The analyzing part will take part in different steps because I want it to be modular, testable, and be able to easily add steps in the future, of which these would be current steps:
EnvironmentInformation
ParseApplicationConfig (different per context) 
AnalyzeEntrypoint
AnalyzeReferences
AnalyzeUnreferenced
RuntimeAvailibilityCheck (CoreContext only)
CompatibilityCheck
VersionCompatibilityCheck (different per context) 
GenerateNotes (see point below)
Filter
Sort
  • The application will also generate textual 'notes' (currently ~20 informative messages) about the application depending on if a condition (ShouldGenerate(IContext context)) on a note is applicable. One of these conditions should also be if it is a FrameworkContext or a CoreContext. For example, some messages can be generated for both contexts, but some only for one, which is very similar to the analyzing part.
Joery
  • 117
  • 5
  • 1
    this somehow resembles [Template method pattern](https://softwareengineering.stackexchange.com/a/271458/31260) – gnat Sep 04 '21 at 20:10
  • @gnat in a sense it does, but from what I understand the point of the template method pattern is to be able to override specific steps, but not have additional steps that can occur in for example the middle of the sequence of steps. – Joery Sep 04 '21 at 20:29
  • 1
    if these additional steps are known in advance then template method is applicable: parent class provides default implemntation of these steps that does nothing and subclasses override it when needed – gnat Sep 04 '21 at 21:00
  • 1
    Yeah; some of the steps could just be placeholders (extension points) - the parent, top level steps provide an overall template sequence, possibly with some steps that are not overridable. Then when overriding, provide as many substeps as you see fit. If there's any implementation-specific input, you'll have to devise a way to provide it to the subclasses, likely at construction time. – Filip Milovanović Sep 05 '21 at 08:48
  • @DocBrown I have added context about what my goal is for this application. – Joery Sep 06 '21 at 19:45
  • I see some people are downvoting my question, it would be helpful if you commented as to why. – Joery Sep 10 '21 at 19:57

3 Answers3

2

You have just two fixed contexts, and probably not more to expect over the next few years(!) - that's a good reason not to generalize now. I would probably start with two controller classes and a common base class:

class CoreAnalyzer : Analyser
{

   virtual void Analyse()
   {
        EnvironmentInformation();   // in base class Analyser
        ParseApplicationConfigCore();   // only in CoreAnalyse
        AnalyzeEntrypoint();          // in base class Analyser
        AnalyzeReferences();          // in base class Analyser
        AnalyzeUnreferenced();
        RuntimeAvailibilityCheck();
        CompatibilityCheck();
        GenerateNotesCore();
        Filter();
        Sort();
   }
}


class FwAnalyzer : Analyser
{

   virtual void Analyse()
   {
        EnvironmentInformation();   // in base class Analyser
        ParseApplicationConfigFw();   // in FwAnalyse
        AnalyzeEntrypoint();          // in base class Analyser
        AnalyzeReferences();          // in base class Analyser
        AnalyzeUnreferenced();
        // no RuntimeAvailibilityCheck here
        CompatibilityCheck();
        GenerateNotesFw();
        Filter();
        Sort();
   }
}

Maybe you don't even need a common base class Analyse, just a helper facade CommonAnalyseSteps where the common functions can be placed. But for the sake of this example, let me start with the above scetch.

Why is this sufficient?

  • The implementation of the individual methods above can make use of other components / classes which can be developed and tested individually, outside of the Analyzer class hierarchy. That's how to achieve testability.

  • The Analyse methods (currently) contains hardly enough logic for the justification of automated tests, and definitely not for a unit test. You may add an integration test for each of them, if you think you need one.

  • Of course you could implement the Analyse() method in the common base class, as a template method, and override methods like ParseApplicationConfig individually. But for just two child classes and no real "logic" in the Analyse method this seems to be overkill. Better follow the "rule of three" and wait until a third child class will become necessary, then generalize.

  • Since you probably don't need a runtime mechanism for configuring which steps to run and which not, I don't see a compelling reason for introducing an IStep interface or a list steps. That just overcomplicates things and does not make the code more readable or more testable, quite the opposite. The situation will change when you get a requirement to let a user decide which steps to apply and which to leave out, but I doubt there is now such a requirement for now.

Doc Brown
  • 199,015
  • 33
  • 367
  • 565
  • Very good advice, the concept of virtual methods in a base def allowing overrides to alter behaviors as needed; including calling the base methods if desired. It's in essence similar to being a decorator in that the super class can not only omit behaviors it may also add to base behaviors. Very flexible and often used in code generators. – John Peters Sep 09 '21 at 07:56
  • 1
    @JohnPeters: well, I think my main point is, the OP were already on the road to overengineering for no apparent reason. Maybe even the use of virtual methods is not necessary, or the use of inheritance. Functional decomposition is probably all they need to keep this program readable and maintainable . – Doc Brown Sep 09 '21 at 09:28
0

This is essentially a "double dispatch" problem. You want to select a different method at runtime depending on both the concrete type of IStep, and the concrete type of IContext.

Since in theory the number of possible methods is (number of IStep implementations) * (number of IContext implementations), in order to keep the complexity under control you should examine which varies less and how many exceptions there are.

pjc50
  • 10,595
  • 1
  • 26
  • 29
0

From the way you describe things, you pass an IContext to each Run implementation, but only some of those implementations should process certain contexts and ignore others, and presumably the exact set of contexts that should be processed or ignored varies for each Run implementation.

So you have two choices here: you can decide within the loop whether to call Run for a particular step, or Run can decide whether to ignore the request or process it. Which you choose is a matter of style, but each case metadata is needed to allow that ignore/process decision to be made. For the "decision in the loop" option, the step must provide the metadata. If Run is called always, then the context must supply it.

You could use down casting and testing the type to determine that metadata. But that's messy and high maintenance as the code has to be edited (in many places if each Run is making the decision`, when a new context type is added. A simpler, and more maintainable solution is to decorate the types with that metadata. As I think you are using C#, you could use attributes for this.

So you might for example have a set of attributes that specify the type of steps that apply to a context. So DoorContext might be [Openable], and a SecureDoorContext might be [Openable] and [Lockable]. Open and Close steps work with any context that is [Openable], whether it a WindowContext, DoorContext or SecureDoorContext. Lock and Unlock would also run with a SecureDoorContext, but also with a Padlock context too.

David Arno
  • 38,972
  • 9
  • 88
  • 121