dimanche 27 novembre 2022

C# compile-time concrete class support for interface-based code

I've been scratching my head around this for weeks now. Sorry if the question is too long, I've tried to make it as short and understandable as possible by simplifying my specific case, stripping away generic parameters, access modifiers etc that don't directly relate to the problem.

Problem

Suppose I have an API cient of type ApiClient that can send commands to API. The commands take arguments and return data. I need methods in ApiClient to do this. But, and this is where things get tricky, I want the user to have a concrete return type, without needing a cast, for compile-time and Intellisense support.

Here's a sample code outline. Suppose I have 20 commands like these:

class AddCommand {
    class Args {}
    class Data {}
}

class ListCommand {
    class Args {}
    class Data {}
}
// and so on...
// Sample declarations:
ApiClient apiClient;
AddCommand.Args addCmdArgs;
ListCommand.Args listCmdArgs;
// and so on for each command's args...

I want the user code to be able to do this:

AddCommand.Data data = apiClient.SendCommand(addCmdArgs);
// Intellisense and compile-time checking available for 'data'

which means SendCommand() needs to be something like this:

Technique 1: Overloading on concrete type

public AddCommand.Data SendCommand(AddCommand.Args args) { ... }
public ListCommand.Data Sendcommand(ListCommand.Args args) { ... }
// and so on for all 20 commands

But this has a problem. Implementing it like this means there needs to be an overload of SendCommand() for each command, in ApiClient, making it more complex and error-prone to add new commands in the future. Overloading it using extension methods instead is similar, it just moves the methods outside ApiClient. The problem remains the same, and the solution still seems "unclean" to me.

Technique 2: One interface-based method

So an idea that comes to mind is to program for interfaces, changing the command types to the following, where commands, args and data each implement their corresponding interfaces:

class AddCommand : ICommand {
    class Args : ICommandArgs {}
    class Data : ICommandData {}
}

class ListCommand : ICommand {
    class Args : ICommandArgs {}
    class Data : ICommandData {}
}
// and so on...

Now there can be a single SendCommand():

ICommandData SendCommand(ICommandArgs args) {}

making it easy to add more commands in the future without changing anything. As a bonus, this solution also looks clean. But now, the user code needs to add a cast, from the interface to a concrete type: (I won't be using var to make it clear which types are being used where. In real code, yes, var can be used to shorten the code)

ICommandData data = apiClient.SendCommand(addCmdArgs);
if (data is AddCommand.Data addCmdData) {
   // ...
}

// or

AddCommand.Data data = (AddCommand.Data) apiClient.SendCommand(addCmdArgs);

This is now a problem because casting is error prone. No one is stopping the user from doing something like:

AddCommand.Data data = (AddCommand.Data) apiClient.SendCommand(listCmdArgs/*!! different args type*/);

which only fails at runtime.

Technique 3: Two unrelated generic arguments

You've probably thought about using generics by now, which, perhaps surprisingly, doesn't solve the problem either. Here's the most appropriate solution:

The command types may or may not implement their interfaces, but SendCommand() changes to:

TData SendCommand<TArgs, TData>(TArgs args) { ... }

// or

TData SendCommand<TArgs, TData>(TArgs args)
    where TArgs : ICommandArgs
    where TData : ICommandData { ... }

depending on whether the command types implement interfaces. There's basically no different between either implementation. But now the user code has to provide type arguments, because they can't be inferred:

AddCommandData data = apiClient.SendCommand<AddCommand.Args, AddCommand.Data>(addCmdArgs);

Yes, the user code now has complete compile-time benefits, but just seeing this code makes me wince. User has to provide both type arguments, and they aren't even bound together in any way, making this valid code:

ListCommand.Data data = apiClient.SendCommand<AddCommand.Args, ListCommand.Data>(addCmdArgs);

that fails only at runtime.

Technique 4: Three related type arguments

Technique 3 is probably the most verbose yet the most error prone technique yet. But it can be refactored by effectively binding the three type arguments to the same command, bringing us to the 4th and last technique that I could think of:

The types change to this:

interface ICommand<TArgs, TData>
    where TArgs : ICommandArgs
    where TData : ICommandData {}
interface ICommandArgs {}
interface ICommandData {}
class AddCommand : ICommand<AddCommand.Args, AddCommand.Data> {
    class Args : ICommandArgs {}
    class Data : ICommandData {}
}
class ListCommand : ICommand<ListCommand.Args, ListCommand.Data> {
    class Args : ICommandArgs {}
    class Data : ICommandData {}
}
// and so on...

and SendCommand() changes to:

TData SendCommand<TCommand, TArgs, TData>(TArgs args)
    where TCommand : ICommand<TArgs, TData>
    where TArgs : ICommandArgs
    where TData : ICommandData { ... }

The user now has to call it like this (brace yourselves):

AddCommand.Data data = apiClient.SendCommand<AddCommand, AddCommand.Args, AddCommand.Data>(addCmdArgs);

The user code now has compile-time support and can't accidently specify unrelated/incorrect type arguments. All three type arguments have to be for the same command. This is the most robust technique yet, and the closest solution to my problem that I could think of. Literally the only issue is that the user code has to specify all three type arguments, add namespace directives for those types, write very long lines etc.

Question

After all of this background information, I have a simple question. Is it possible to achieve what I'm trying to do, with a "clean" solution? At one end of the spectrum is Technique 1, very clean for the user, but inconvenient for the API client library. Gradually moving along the spectrum, we reach Technique 4, very clean for the library but inconvenient for the user code. I'm currently using Technique 1, overloading on the basis of concrete types, and I see Technique 4 as the closest solution to my problem, minus the disadvantages of course. Is there another technique, hopefully better that all of these? Or do I have to continue using overloads for different command types? Is there some design pattern that I'm missing, is this just another dumb question, or is something that has genuinely bugged software architecture and doesn't really have a satisfactory solution?

Aucun commentaire:

Enregistrer un commentaire