annuaire tech France

Tinyweb Series, Part 5: The To-Do Demo

This is the finale of the Tinyweb series. We have built a front handler and dispatcher in part 1, API results in part 2, a container and filters in part 3, and views and model binding in part 4. Now we use all of it to build the classic demo: a to-do list.

What the app does

  • Shows a list of tasks.
  • Adds a task from a form.
  • Marks a task done.
  • Deletes a task.
  • Offers the list as JSON for other programs.

A to-do list is a cliché because it is small enough to finish and large enough to touch every layer.

The model and the store

public class TodoItem
{
    public int Id { get; set; }
    public string Text { get; set; }
    public bool Done { get; set; }
}

public interface ITodoStore
{
    IEnumerable<TodoItem> All();
    TodoItem Find(int id);
    void Add(string text);
    void Save(TodoItem item);
    void Remove(int id);
}

For the demo, an in-memory implementation is plenty:

public class MemoryTodoStore : ITodoStore
{
    private readonly List<TodoItem> _items = new List<TodoItem>();
    private int _nextId = 1;

    public IEnumerable<TodoItem> All() { return _items.ToList(); }
    public TodoItem Find(int id) { return _items.FirstOrDefault(i => i.Id == id); }

    public void Add(string text)
    {
        _items.Add(new TodoItem { Id = _nextId++, Text = text });
    }

    public void Save(TodoItem item) { /* already in memory */ }
    public void Remove(int id) { _items.RemoveAll(i => i.Id == id); }
}

Because the controller depends on the interface, swapping this for a database later touches only the composition root. Note that a shared in-memory list is not thread-safe, which would matter under real traffic; add a lock or use a database before treating it as more than a demo.

The controller

public class TodoController
{
    private readonly ITodoStore _store;

    public TodoController(ITodoStore store) { _store = store; }

    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);
    }

    public TinyResult Post(TodoInput input)
    {
        if (string.IsNullOrWhiteSpace(input.Text))
            return new StatusResult(400, "Text is required");

        _store.Add(input.Text.Trim());
        return new RedirectResult("/todo");
    }

    public TinyResult Complete(int id)
    {
        var item = _store.Find(id);
        if (item == null) return new StatusResult(404, "No such task");

        item.Done = true;
        _store.Save(item);
        return new RedirectResult("/todo");
    }

    public TinyResult Delete(int id)
    {
        _store.Remove(id);
        return new RedirectResult("/todo");
    }

    public TinyResult Json()
    {
        return new JsonResult(_store.All().ToList());
    }
}

Each action is a few lines and returns a result. None of them know about HttpContext, which is exactly what we wanted when we started the series.

The view

<h1>{{Title}}</h1>

<form method="post" action="/todo">
  <input type="text" name="Text">
  <button type="submit">Add</button>
</form>

<ul>
{{#Items}}  <li>{{Text}}
    <a href="/todo/complete?id={{Id}}">done</a>
    <a href="/todo/delete?id={{Id}}">remove</a>
  </li>
{{/Items}}</ul>

A caveat worth stating plainly: using links that change data (the done and remove links) is fine for a demo but poor practice, because any crawler or prefetching browser can trigger them. In a real app, make those forms that submit with POST.

Wiring it up

The composition root registers the store and the filters:

public class Global : HttpApplication
{
    protected void Application_Start()
    {
        var store = new MemoryTodoStore();   // one shared instance

        var container = new TinyContainer();
        container.Register<ITodoStore>(() => store);

        Tinyweb.UseControllerFactory(container);
        Tinyweb.UseViewEngine(new SimpleViewEngine("~/Views"));
        Tinyweb.AddFilter(new LogFilter());
    }
}

The registration returns the same store each time, so the list survives between requests while controllers themselves are created fresh.

Testing the controller

[TestMethod]
public void Complete_marks_item_done()
{
    var store = new MemoryTodoStore();
    store.Add("write tests");
    var controller = new TodoController(store);

    controller.Complete(1);

    Assert.IsTrue(store.Find(1).Done);
}

No server, no browser, no mocks. If you ever need to test code that reaches for the clock or a static, remember funcy testing and isolating code with Moles and Fakes.

What you learned

  1. One handler, one dispatcher, and conventions are enough to run an application.
  2. Result objects keep controllers testable.
  3. A factory seam is all dependency injection needs.
  4. Filters keep cross-cutting code out of actions.
  5. View models and a binder keep the web edge thin.

Where to take it next

Add validation messages, persistence in a database, authentication, anti-forgery tokens and a proper test suite for the dispatcher. Better still, compare each piece with a mature framework and note what it does that yours does not. That comparison is where the real learning is, and the reason building a framework is worth doing at least once.

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