In this article I'm going to show you a way to reduce your controller code and make it more manageable and robust. We are going to be using the Builder Pattern for solving this task so let's dive in.
Be sure to checkout the GitHub Repository for this article: https://github.com/MartinShishkov/blog-demos/tree/master/clean-controllers/CleanControllers.Web
For those who don't know how the Builder Pattern works, here is a brief explanation:
The Builder Design Pattern introduces and object that is gathering data and attempts to build the actual object we need to work with and it either ensures that it is in a valid state or returns a list of error codes.
public class Person
{
public string FirstName { get; private set;}
public string LastName { get; private set;}
public int Age { get; private set;}
public static readonly string ERROR_FIRST_NAME_LENGTH
= "person.firstname.length";
public static readonly string ERROR_LAST_NAME_LENGTH
= "person.lastname.length";
public static readonly string ERROR_AGE_INVALID
= "person.age.invalid";
public class Builder
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
public BuildResult<Person> Build()
{
var errorCodes = new List<string>();
//...perform validations...
var person = new Person{
FirstName = FirstName,
LastName = LastName,
Age = Age
};
return new BuildResult<Person>(errorCodes, person);
}
}
}
Using this code we can be sure that we get a valid Person.
What's the problem with your typical controller? I've seen many web app back-ends and for the most part the common issue between them is that the controllers are bloated with business logic: validation of objects, order of operations, events, database access and so on.
So, here's how you can benefit from all of this:*If your application is relatively small this approach might be an overkill and you have to decide if you actually need it or not. For large applications it has proven itself to be really helpful.
- More granular, complex and explicit control over how your model is validated
- Ability to localize your error messages in a clean fashion
- Testable models
- Less bugs and more maintainability
1. Setup
I will be using the GitHub repository as a reference, so you can check the full demo there.
Our domain object is a type Person. It has a FirstName, LastName and Age properties and we would really like those to be valid anytime we work with a Person instance.
Person.cs
public class Person
{
public Person(string firstName, string lastName, int age)
{
FirstName = firstName;
LastName = lastName;
Age = age;
}
public string FirstName { get; }
public string LastName { get; }
public int Age { get; }
}
We're starting with a controller called DirtyController. It has an [HttpPost] DoStuff() method which handles our form submissions.
You can find the form source code here: https://github.com/MartinShishkov/blog-demos/blob/master/clean-controllers/CleanControllers.Web/Views/Shared/_SimpleForm.cshtml
Now let's look at the controller
public class DirtyController : Controller
{
[HttpPost]
public IActionResult DoStuff(SimpleFormPostModel model)
{
// this shouldn't be here
if (model.Consent == null || model.Consent.Contains("yes") == false)
{
ModelState.AddModelError(
nameof(SimpleFormPostModel.Consent),
"You have to give your consent"
);
}
if (ModelState.IsValid == false)
return new JsonResult(new {
// even getting the error messages is an abomination
errors = ModelState.Values
.SelectMany(e =>
e.Errors.Select(err => err.ErrorMessage))
}) {StatusCode = (int)HttpStatusCode.BadRequest};
try
{
var person =
new Person(model.FirstName, model.LastName, model.Age);
// ...execute operations with 'person'...
return new JsonResult(new
{
message = "Everything went smooth"
}) {StatusCode = (int)HttpStatusCode.OK};
}
catch (Exception e)
{
return new JsonResult(new
{
errors = new string[]{e.Message}
}){ StatusCode = (int)HttpStatusCode.InternalServerError};
}
}
}
We can see here that there is some validation logic outside of the model responsible for storing the form data.
ASP.NET allows us to annotate our post model with validation attributes that can help us address the most trivial validation requirements - minimum length strings, required properties, number ranges etc. We can even create our own validation attributes, however this can get cumbersome because you might need to have an access of the whole data object context instead of just the value of the current field.
SimpleFormPostModel.cs:
public class SimpleFormPostModel
{
[Required]
[MinLength(3,
ErrorMessage = "This has to be at least 3 chars long dammit")]
public string FirstName { get; set; }
[Required]
[MinLength(3,
ErrorMessage = "This has to be at least 3 chars long dammit")]
public string LastName { get; set; }
[Required]
[Range(minimum: 18, maximum: 1000,
ErrorMessage = "You have to be between 18 and 1000 years old")]
public int Age { get; set; }
[Required]
public string[] Consent { get; set; }
}
These ErrorMessage fields are quite nice, however there's one drawback to them: localization. Although it's not impossible to localize your ValidationAttribute error messages this requires extra work, so I offer a different approach.
2. Introducing the Builder
Lets refactor our app and make it work with a builder. First off, the builder will have a Build() method and this should return a BuildResult<Person>
So we can create our builder object as a nested type of Person:
public class Person
{
// this constructor is obsolete;
//the Person class needs only properties with private setters
public Person(string firstName, string lastName, int age)
{
FirstName = firstName;
LastName = lastName;
Age = age;
}
public string FirstName { get; set;}
public string LastName { get; set;}
public int Age { get; set;}
public static readonly string ERROR_FIRST_NAME_LENGTH
= "person.firstname.length";
public static readonly string ERROR_LAST_NAME_LENGTH
= "person.lastname.length";
public static readonly string ERROR_AGE_INVALID
= "person.age.invalid";
public static readonly string ERROR_CONSENT_REQUIRED
= "person.consent.required";
public class Builder
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
public string[] Consent { get; set; }
public BuildResult<Person> Build()
{
var errorCodes = new List<string>();
if (string.IsNullOrEmpty(FirstName))
errorCodes.Add(ERROR_FIRST_NAME_LENGTH);
if(string.IsNullOrEmpty(LastName))
errorCodes.Add(ERROR_LAST_NAME_LENGTH);
if(Age < 18)
errorCodes.Add(ERROR_AGE_INVALID);
if(Consent == null || Consent.Contains("yes") == false)
errorCodes.Add(ERROR_CONSENT_REQUIRED);
// kinda dumb
var person = errorCodes.Any()
? null
: new Person(FirstName, LastName, Age);
return new BuildResult<Person>(errorCodes, person);
}
}
Lets also create a new controller and call it CleanController. Rewriting the DoStuff() method as follows:
public class CleanController : Controller
{
[HttpPost]
public IActionResult DoStuff(Person.Builder builder)
{
var buildResult = builder.Build();
if(buildResult.IsValid == false)
return new JsonResult(new {
errorCodes = buildResult.ErrorCodes
}) {StatusCode = (int)HttpStatusCode.BadRequest};
try
{
// we have a valid person at this point for sure
var person = buildResult.Value;
return new JsonResult(new
{
message = "Everything went smooth"
}) {StatusCode = (int)HttpStatusCode.OK};
}
catch (Exception e)
{
return new JsonResult(new
{
errors = new string[]{e.Message}}){
StatusCode = (int)HttpStatusCode.InternalServerError
};
}
}
}
You can now see that we've centralized our validation logic in the builder. This makes it more testable and enables us to work with error codes instead of hardcoded error messages. We can then use the error messages however we find suitable.