Tinyweb Series, Part 4: Views, View Models and Model Binding
Part four of the Tinyweb series gives the framework a face. So far it returns text, JSON and XML. Real applications also render HTML and accept form posts, and that needs three things: a view engine, a view model convention and a model binder. If you are new to the series, begin with part 1.
Views, the simplest thing that works
A view engine turns a template and some data into HTML. Tinyweb will use the most basic approach that still teaches the idea: a template file with placeholders such as {{Title}}.
<!-- Views/Todo/Index.html -->
<h1>{{Title}}</h1>
<ul>
{{#Items}} <li>{{Text}}</li>
{{/Items}}</ul>
This borrows the shape of well-known logic-less templates. Writing the renderer is a nice exercise in string handling, but you can also plug in any existing engine behind the same interface:
public interface IViewEngine
{
string Render(string viewPath, object model);
}
The view result
Controllers do not render anything. They choose a view and a model, and a result does the work:
public class ViewResult : TinyResult
{
private readonly string _view;
private readonly object _model;
public ViewResult(string view, object model)
{
_view = view;
_model = model;
}
public override void Execute(HttpContext context)
{
var html = Tinyweb.ViewEngine.Render(_view, _model);
context.Response.ContentType = "text/html";
context.Response.Write(html);
}
}
Because ViewResult is just data, a test can check which view was chosen and what the model contained without rendering a single tag.
View models
A view model is a class shaped for one screen. It is not your domain entity and it is not the database row. It holds exactly what the page shows.
public class TodoListViewModel
{
public string Title { get; set; }
public List<TodoItemViewModel> Items { get; set; }
}
public class TodoItemViewModel
{
public int Id { get; set; }
public string Text { get; set; }
public bool Done { get; set; }
}
The controller maps from entities to view models:
public TinyResult Index()
{
var model = new TodoListViewModel
{
Title = "My tasks",
Items = _store.All().Select(t => new TodoItemViewModel
{
Id = t.Id, Text = t.Text, Done = t.Done
}).ToList()
};
return new ViewResult("Todo/Index", model);
}
That small mapping step has a big payoff. Templates cannot reach into your domain, you decide what is exposed, and changing the database does not break the page.
Model binding
Forms arrive as name/value pairs. Pulling them out one at a time with request.Form["text"] is tedious and error-prone. A model binder fills a typed object from the request.
public class TodoInput
{
public string Text { get; set; }
public int Priority { get; set; }
}
public TinyResult Create(TodoInput input)
{
_store.Add(input.Text, input.Priority);
return new RedirectResult("/todo");
}
Writing the binder
The binder creates the parameter type, looks at its writable properties and converts matching form or query values:
public static class ModelBinder
{
public static object Bind(Type type, NameValueCollection values)
{
if (IsSimple(type))
return null; // handled by the parameter-name rule
var model = Activator.CreateInstance(type);
foreach (var prop in type.GetProperties().Where(p => p.CanWrite))
{
var raw = values[prop.Name];
if (raw == null) continue;
try
{
var converted = Convert.ChangeType(raw, prop.PropertyType, CultureInfo.InvariantCulture);
prop.SetValue(model, converted, null);
}
catch (FormatException) { /* leave default; record an error */ }
}
return model;
}
private static bool IsSimple(Type t)
{
return t.IsPrimitive || t == typeof(string) || t == typeof(decimal);
}
}
Two details matter. Use the invariant culture for conversion so that behaviour does not change with the server settings. And do not swallow failures silently in a real application: collect them into an error collection the controller can inspect, which is the basis of validation.
Binding rules in the dispatcher
For each action parameter, the dispatcher applies a simple order:
- If the type is simple (number, string, bool), look up the parameter name in the query and form.
- Otherwise treat it as a complex type and call the binder.
- If nothing is found, use the default value for the type.
Be careful with mass assignment. If your binder can set any property, a malicious post can set properties you never put on the form. Binding to a dedicated input class, as above, rather than to an entity is the simplest defence.
Validation hook
Add an Errors collection to the request and let controllers check it:
if (request.Errors.Any())
return new ViewResult("Todo/New", input);
Redisplaying the form with the submitted values and messages is the standard pattern. After a successful post, redirect to a GET page so a refresh does not repeat the submission.
Testing
The binder is a pure function from a collection to an object, so it is easy to test with a hand-built NameValueCollection. If your binder needs the clock or other ambient state, remember the options: delegates as in funcy testing, or isolating code with Moles and Fakes when you must.
We now have everything we need for a complete application. In part 5 we put it all together as a to-do app.
More .NET guides
- Moles and Pex: Isolating .NET Code and Generating Tests (Now Fakes and IntelliTest)
- Building a Web Framework Is the New Black
- Funcy Testing: Using Func and Action Instead of Interfaces
- MEF: Making Extensibility Fun
- A Gentle Introduction to WCF
- Tinyweb Series, Part 1: Getting Started
- Tinyweb Series, Part 2: Building APIs
- Tinyweb Series, Part 3: Dependency Injection and Filters
- Tinyweb Series, Part 5: The To-Do Demo
- The Ordered Jobs Kata: Dependency Sorting with TDD in C#
- JavaScript: It's Up Your Sleeve
- C# News from the Future: What If...?
- What's After Unit Testing?
- The "If It Isn't Broken, Don't Fix It" Mentality
- Are You Failing Enough?
- Don't Bother Rewriting It: The Case for Gradual Refactoring
- Was Dave a Genius? Lessons from a Legacy Codebase
- Preston Tweetup: Why Developer Meetups Matter
- How Developers Really Learn
- Sustainable Side Projects: Keeping Personal Code Alive
- Parallel.For and Silver Bullets
Looking for a software partner? Browse the independent directory of French tech companies (web agencies, IT consultancies, software publishers; the directory itself is in French).