Breaking down the problem
What you've done right is identified that all exporters work the same. As a basic example (which I will expand on), this means something along the lines of:
IExporter exporter = GetWhateverExporterYouWant();
exporter.Export("C:\\output.txt");
But you've also noticed that the input value (or rather its type) changes based on the exporter type.
IExporter exporter = GetWhateverExporterYouWant();
exporter.Export("C:\\output.txt", myArrayData); // If it's ArrayExporter
exporter.Export("C:\\output.txt", myContext, myTable); // If it's DatabaseExporter
This is where it breaks down, because a common interface (the Export
method) cannot have different signatures in it's different implementations.
You've already noticed that subclasses are still able to define their own personal contructor arguments. That is a very valid point, but you might not want to go down that route.
Where this makes sense is in cases where you use a global dependency injection, where the constructor doesn't really take in "input values", but rather "injected dependencies".
On a technical level, they are the same, but on a functional level they are not. Injected dependencies are usually data-agnostic (the decision on which dependency to use is usually decided based on external factors), whereas input values are contextual to the current request/execution and are expected to change all the time.
As I see it, there are three options here. I'm favoring the third option, but I want to discuss the others first so you know why they are(n't) applicable to you.
1. Explicit instantiation
You can still use the contructor argument approach, but you can't rely on some blind preset framework to do it for you because you need your input values to be easily and clearly changeable.
When explicit instantiation becomes more than just a one-or-two-liner; good practice tends to push you into the realm of factories, which is what you're currently doing.
IExporter CreateDatabaseExporter(DbContext dbContext, string tableName);
IExporter CreateArrayExporter(string[] arrayData);
Is this a sane approach to this problem? It feels weird to have all of the different construction implementations in a single class, but maybe that's the point of the factory.
On a technical level, there is nothing wrong with this. However, if your factory methods are nothing more than a pass-through, then the factory has no purpose. I'm talking about something like:
public IExporter CreateDatabaseExporter(DbContext dbContext, string tableName)
{
return new DatabaseExporter(dbContext, tableName);
}
public IExporter CreateArrayExporter(string[] arrayData)
{
return new ArrayExporter(arrayData);
}
The factory methods here are nothing but an empty shell. To the caller, there is nothing to gain by using the factory. With the factory:
IExporter exporter = ExporterFactory.CreateArrayExporter(arrayData);
Without the factory:
IExporter exporter = new ArrayExporter(arrayData);
The purpose of a factory is deciding which subclass to use. The bonus to the caller is that the caller doesn't need to know or decide between the different options. It just tells the factory "give me whatever suits my purpose please". By making passthrough factory methods, you do not actually give the factory a purpose.
Think of it this way: when you go to a restaurant and ask for a spaghetti arrabiata, you trust the chef to know what spices to use in your meal. You can't be bothered figuring it all out, you tell the chef "just make it tasty".
If instead the chef required you to list every spice you want him to use, then there is no purpose to the chef (you might as well cook your own arrabiata then).
To be fair, there is one minor benefit. Even with a passthrough method, you ensure that the constructor is only called in one location (the factory method), rather than all over the place.
However, it's unclear whether that actually yields an advantage. It may yield an advantage if you need to change all the constructor calls to a new class, but in reality that's easily fixed by doing a refactoring of the class name (which changes all references in one go).
Additionally, this is a straw on the camel's back scenario. The additional layer is not necessarily bad or counterproductive even if it has no functional purpose; but if you keep applying that same logic to every part of your application and keep putting more and more (pointless) straws on the camel's back, eventually it will break.
I would reevaluate the purpose of the factory, and whether it's really adding something compared to having the callers simply instantiate their own exporter.
2. Generic input parameters
This might not be applicable because it comes with its own baggage. Doing this requires you to know the type of the input parameters (there is a contrived way to even avoid that but its complexity is IMO much too high and it massively detracts from maintainability and readability).
public interface IExporter<TSource>
{
void Export(string outputPath, TSource data);
}
Then, when you implement the interface, you use concrete types:
public class PersonExporter : IExporter<Person>
{
void Export(string outputPath, Person data) { }
}
If there is more than one input value, make a DTO which contains all values:
public class DatabaseValues
{
public Context MyContext { get; set; }
public string TableName { get; set; }
}
public class DatabaseExporter : IExporter<DatabaseValues>
{
void Export(string outputPath, DatabaseValues data) { }
}
You can also use a different generic type as the concrete class.
public class ArrayExporter : IExporter<IEnumerable<T>>
{
void Export(string outputPath, IEnumerable<T> data) { }
}
However, you might want to subclass this further until you get to a concrete class, or at least add some for of constraint on the generic parameter:
public interface IExportableData
{
IEnumerable<string> GetExportValues();
}
public class ArrayExporter : IExporter<IEnumerable<T>> where T : IExportableData
{
void Export(string outputPath, IEnumerable<T> data) { }
}
But the generic approach is only valuable when there is sufficient shared logic in the generic IExporter<T>
class. Otherwise it still a passthrough, a hollow shell, and it usually is not worth the effort of creating a pointless hollow shell. It (slightly) bloats the code, doesn't add any functional value, and actually detracts from your polymorphic approach at it requires you to know the type of the input values at all times (you can no longer refer to something as an IExporter
, you always have to refer to it as a IExporter<Person>
, IExporter<DatabaseValues
, etc...
3. No common base type (and/or splitting the responsibilities)
Just because two classes have the same method name/signature does not necessarily mean that they must share a base class. E.g. just because the classes Person
, Disease
and Album
all have a Name
property, does not mean that you're required to make them derive from a shared BaseNamedObject
or IObjectWithAName
class.
The same might be happening here for your exporters. It all hinges on one question: what benefit are you getting from the common base type? I don't quite see a big benefit based on your example code. It feels like you're trying to achieve something like:
public class FooExporter : IExporter
{
public void Export(string outputPath, Foo foo)
{
// A lot of code that parses your Foo as a string
File.WriteAllText(outputPath, "foo as a string");
}
}
This is an oversimplification, but I'm struggling to see what logical steps your exporters are going to contain that are not intrinsically related to the input data and its type. Where is the export logic, the logic that is shared between all IExporter
objects? Is there that much logic (more than a one-or-two-liner) that it's really necessary to create an inheritance/implementation structure for it?
An export, in my opinion, is innately defined by the data it's intended to parse/export. 95% of the effort goes to parsing the data. Writing the data is usually trivially simple to a point where it just uses a .NET construct (StreamWriter
or any of the File.Write...()
or File.Append...()
variants) Different data leads to a wholly different approach, most likely with little to no reusable pieces. Which means that a common base type offers little purpose.
It almost feels like your IExporter
(and implementations) are more intended as a IFileWriter
type, where they mostly just define how to write the data to disk (which is something that can be generalized, but is often already a one-liner that hardly needs an additional wrapper), rather than how to parse the data (which, as you've discovered, can't really be generalized).
It may be better to separate the data generation and data storage responsibilities, because then your data storage can be a single class (which takes in string data).
For the data generation, i.e. the taking in of a certain type of input parameter and parsing it down to an exportable format, it may be better to avoid trying to foist a base class on them just because you can say that "they both export things". Exporting is incredibly vague and such a varied topic that it's hard to find any strong commonalities.
arrayData
oroutputPath
?