annuaire tech France

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:

  1. If the type is simple (number, string, bool), look up the parameter name in the query and form.
  2. Otherwise treat it as a complex type and call the binder.
  3. 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

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).