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
- One handler, one dispatcher, and conventions are enough to run an application.
- Result objects keep controllers testable.
- A factory seam is all dependency injection needs.
- Filters keep cross-cutting code out of actions.
- 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
- 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 4: Views, View Models and Model Binding
- 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).