Skip to main content

04.2 - Files, Directories and Persistence Design

Recap

04.1 - JSON turned GameState and GameConfiguration into text. This lecture puts that text on disk — in the right place, safely — and then designs the repository contracts that A3 (DAL.Json) and A4 (DAL.EF) both implement, while the engine from 03.2 - Game Engine Design stays untouched.

By the end of this lecture you should be able to:

  • Read, write, list and delete files with File, Directory and Path without a single hardcoded path.
  • Put saves in the user's home directory in a cross-platform way and write them atomically.
  • Decide what a saved game must contain and when a DTO is worth it.
  • Define IConfigRepository and IGameRepository in GameEngine and implement them in DAL.Json.
  • Version the save format and seed preset configurations on first run.
Demo code

Lecture demos: csharp-2026-fall

System.IO — three static classes

File

var path = "classic.config.json";

File.WriteAllText(path, json); // create or overwrite, UTF-8
var text = File.ReadAllText(path); // the whole file as one string
var exists = File.Exists(path); // never throws
File.Delete(path); // no error when the file does not exist
File.Copy(path, "backup.json", overwrite: true);
File.Move(path, "renamed.json", overwrite: true);

var lines = File.ReadAllLines(path); // string[]
File.AppendAllText("log.txt", $"{DateTime.UtcNow:O} saved {path}\n");

Every read/write has an async twin with the same name: await File.ReadAllTextAsync(path), await File.WriteAllTextAsync(path, json). A console app can stay synchronous; the web app in Week 13 uses the async versions.

Directory

Directory.CreateDirectory(folder);               // creates missing parents too; no error when it exists
var exists = Directory.Exists(folder);
string[] files = Directory.GetFiles(folder, "*.game.json"); // full paths, this folder only
foreach (var file in Directory.EnumerateFiles(folder, "*.config.json", SearchOption.TopDirectoryOnly))
{
Console.WriteLine(Path.GetFileName(file));
}
Directory.Delete(folder, recursive: true);

GetFiles returns everything at once as an array; EnumerateFiles is lazy and LINQ-friendly. The pattern supports * and ? only — no regex.

Path

Never glue paths together with + "/" +.

var folder = Path.Combine(home, ".icd0008", "connect-four");   // the right separator for the OS
var file = Path.Combine(folder, $"{state.Id}.game.json");

Path.GetFileName(file); // "8f1c....game.json"
Path.GetFileNameWithoutExtension(file); // "8f1c....game" - removes only the LAST extension
Path.GetExtension(file); // ".json"
Path.GetDirectoryName(file); // the folder
Path.ChangeExtension(file, ".bak");
Path.GetTempPath(); // a writable scratch folder - good for tests
Path.DirectorySeparatorChar; // '/' on macOS and Linux, '\' on Windows

Because GetFileNameWithoutExtension("Classic.config.json") returns Classic.config, strip a two-part suffix yourself:

const string ConfigSuffix = ".config.json";
var name = Path.GetFileName(file)[..^ConfigSuffix.Length]; // "Classic"

Where do the files go?

Not next to the executable (bin/Debug/... is wiped by dotnet clean) and not in the repository. The user's home directory plus a folder for your app:

namespace DAL.Json;

public static class AppPaths
{
public static string Root { get; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".icd0008", "connect-four");

public static string Configs => Path.Combine(Root, "configs");
public static string Games => Path.Combine(Root, "games");
}

That resolves to C:\Users\andres\.icd0008\connect-four on Windows, /Users/andres/.icd0008/connect-four on macOS and /home/andres/.icd0008/connect-four on Linux — from the same line of code. SpecialFolder.ApplicationData (AppData\Roaming, ~/.config) is the more "proper" location; either is fine for the course.

danger

A literal C:\Users\andres\... or /Users/andres/... anywhere in your code breaks the fresh-clone requirement — the TA runs your solution on a different laptop. Same rule for the SQLite file in A4.

Streams, briefly

File.ReadAllText is a convenience wrapper around a FileStream. You meet the stream API when a file is big, when data comes from the network, or when you want the serializer to write straight to the file:

await using (var stream = File.Create(path))                       // FileStream
{
await JsonSerializer.SerializeAsync(stream, state, JsonDefaults.Options);
}

using (var reader = new StreamReader(path)) // text over a stream
{
while (reader.ReadLine() is { } line)
{
Console.WriteLine(line);
}
}

using (var writer = new StreamWriter(path, append: true))
{
writer.WriteLine("appended");
}

using disposes the stream, which flushes buffers and releases the file handle. Forget it and you get empty files and "file is in use by another process" errors.

Atomic writes

Power off or a crash in the middle of WriteAllText leaves half a JSON file; the next start greets you with JsonException. Write to a temporary file first and rename — a rename on the same volume either happens completely or not at all:

namespace DAL.Json;

public static class FileHelpers
{
public static void WriteAtomic(string path, string content)
{
var temp = path + ".tmp";
File.WriteAllText(temp, content);
File.Move(temp, path, overwrite: true);
}

public static string SafeName(string name)
{
var invalid = Path.GetInvalidFileNameChars();
return new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray());
}
}

IO exceptions

FileNotFoundException and DirectoryNotFoundException (both derive from IOException), UnauthorizedAccessException (permissions), plain IOException (disk full, file locked) — plus JsonException when the content is not what you expect. Check File.Exists before reading to turn the common case into a clear message, and catch the rest where you can tell the user something useful:

try
{
var state = gameRepository.Get(id);
GameRunner.Run(state, human, human, gameRepository);
}
catch (KeyNotFoundException)
{
Console.WriteLine("That saved game no longer exists.");
}
catch (JsonException e)
{
Console.WriteLine($"The save file is corrupt: {e.Message}");
}
catch (IOException e)
{
Console.WriteLine($"Could not read the save: {e.Message}");
}

Persistence design (A3)

What to store

StoreWhy
Configuration (inside the game)a saved game must be playable even if the preset changes
State: board, next player, phase, pending captureto continue exactly where the game stopped
Move historyreplay, undo, "last move" highlight, debugging a rules bug from a real save
Players: names and, from A5, human/AI + difficultythe web app (A6) shows who plays, the AI needs to know its own turn
Timestamps: CreatedAtUtc, SavedAtUtcsorting the game list, "continue last game"
Ids: Guid per game, the name per configurationstable keys in files, database rows and URLs
Format versionso that next month's program can still read this month's files

Do not store what you can recompute: status, legal moves, piece counts. Stored derived data eventually disagrees with the data it was derived from.

DTO vs domain object

The domain object is GameState — what the engine works with. A DTO (data transfer object) is a plain class shaped for the file. Use the domain object directly when it is already file-friendly: public properties, no tuples, no [,]. Introduce a DTO when the domain type has something the serializer cannot handle, or when you want to change the engine without breaking old files. The tuple move history from lecture 04.1 is exactly such a case:

using GameEngine;

namespace DAL.Json;

public class GameStateDto
{
public int Version { get; set; } = SaveFormat.Current;
public Guid Id { get; set; }
public required GameConfiguration Config { get; set; }
public EGamePiece[][] Board { get; set; } = [];
public EGamePiece NextMoveBy { get; set; }
public List<MoveDto> Moves { get; set; } = [];
public DateTime CreatedAtUtc { get; set; }
public DateTime SavedAtUtc { get; set; }
}

public class MoveDto
{
public int Row { get; set; }
public int Col { get; set; }
}

public static class GameStateMapper
{
public static GameStateDto ToDto(GameState state) => new()
{
Id = state.Id,
Config = state.Config,
Board = state.Board,
NextMoveBy = state.NextMoveBy,
Moves = state.Moves.Select(m => new MoveDto { Row = m.Row, Col = m.Col }).ToList(),
CreatedAtUtc = state.CreatedAtUtc,
SavedAtUtc = DateTime.UtcNow,
};

public static GameState ToDomain(GameStateDto dto) => new()
{
Id = dto.Id,
Config = dto.Config,
Board = dto.Board,
NextMoveBy = dto.NextMoveBy,
Moves = dto.Moves.Select(m => (m.Row, m.Col)).ToList(),
CreatedAtUtc = dto.CreatedAtUtc,
};
}

The DTO lives in DAL.Json, next to the code that reads and writes it. GameEngine never sees it. In A4 the EF entity classes play the same role for the database.

File naming and ids

~/.icd0008/connect-four/
├── configs/
│ ├── Classic.config.json {name}.config.json - the name is the key
│ ├── Cylinder.config.json
│ └── My-weird-board.config.json
└── games/
├── 8f1c2d3e-....game.json {guid}.game.json - the id is the key
└── b2a7e0c1-....game.json
  • A configuration's key is its name. Sanitise it with FileHelpers.SafeName and remember that Windows and macOS file systems are case-insensitive — classic and Classic are the same file.
  • A game's key is its Guid: unique without a database, generated in the engine, identical in JSON (A3), SQLite (A4) and the URL (A6). That is what makes a console-started game continuable in the web app.

CRUD semantics

OperationContract
List()lightweight: names, or (Id, Name, SavedAtUtc); never full states
Get(key)the object, or KeyNotFoundException when missing — not null, not a default object
Save(object)upsert: create or overwrite by key; saving the same id twice leaves one file
Delete(key)idempotent: no error when nothing exists

Decide these once, write them into the interface's XML doc comments, and make every implementation honour them. In A4 the contract tests run against both DAL.Json and DAL.EF — they can only pass if the semantics are identical. With JSON files, List() has to open every game file to read the name and timestamp; with a database it is one query. The interface does not care.

The interfaces live in GameEngine

namespace GameEngine;

public interface IConfigRepository
{
List<string> List();
GameConfiguration Get(string name);
void Save(GameConfiguration config);
void Delete(string name);
}

public interface IGameRepository
{
List<(Guid Id, string Name, DateTime SavedAtUtc)> List();
GameState Get(Guid id);
void Save(GameState state);
void Delete(Guid id);
}

The interface sits in the project everybody references; the implementation sits in a project only the composition root (Program.cs) references. The game loop takes an IGameRepository and never learns whether it is talking to a folder or a database.

ConfigRepositoryJson

using System.Text.Json;
using GameEngine;

namespace DAL.Json;

public class ConfigRepositoryJson : IConfigRepository
{
private const string Suffix = ".config.json";
private readonly string _folder;

// the folder parameter exists for tests - point it at a temp directory
public ConfigRepositoryJson(string? folder = null)
{
_folder = folder ?? AppPaths.Configs;
Directory.CreateDirectory(_folder);
SeedPresets();
}

public List<string> List() =>
Directory.EnumerateFiles(_folder, "*" + Suffix)
.Select(file => Path.GetFileName(file)[..^Suffix.Length])
.OrderBy(name => name)
.ToList();

public GameConfiguration Get(string name)
{
var path = PathFor(name);
if (!File.Exists(path))
throw new KeyNotFoundException($"Configuration '{name}' not found.");

var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<GameConfiguration>(json, JsonDefaults.Options)
?? throw new JsonException($"Configuration '{name}' is empty.");
}

public void Save(GameConfiguration config)
{
var json = JsonSerializer.Serialize(config, JsonDefaults.Options);
FileHelpers.WriteAtomic(PathFor(config.Name), json);
}

public void Delete(string name) => File.Delete(PathFor(name));

private string PathFor(string name) => Path.Combine(_folder, FileHelpers.SafeName(name) + Suffix);

private void SeedPresets()
{
if (List().Count > 0) return; // only on first run - never overwrite the user's edits
foreach (var preset in Presets.All)
{
Save(preset);
}
}
}

GameRepositoryJson

Same structure, written with a primary constructor this time:

using System.Text.Json;
using GameEngine;

namespace DAL.Json;

public class GameRepositoryJson(string? folder = null) : IGameRepository
{
private const string Suffix = ".game.json";
private readonly string _folder = EnsureFolder(folder ?? AppPaths.Games);

private static string EnsureFolder(string folder)
{
Directory.CreateDirectory(folder);
return folder;
}

public List<(Guid Id, string Name, DateTime SavedAtUtc)> List() =>
Directory.EnumerateFiles(_folder, "*" + Suffix)
.Select(ReadDto)
.OrderByDescending(dto => dto.SavedAtUtc)
.Select(dto => (dto.Id, dto.Config.Name, dto.SavedAtUtc))
.ToList();

public GameState Get(Guid id)
{
var path = PathFor(id);
if (!File.Exists(path))
throw new KeyNotFoundException($"Game {id} not found.");

return GameStateMapper.ToDomain(ReadDto(path));
}

public void Save(GameState state)
{
var dto = GameStateMapper.ToDto(state);
var json = JsonSerializer.Serialize(dto, JsonDefaults.Options);
FileHelpers.WriteAtomic(PathFor(state.Id), json);
}

public void Delete(Guid id) => File.Delete(PathFor(id));

private string PathFor(Guid id) => Path.Combine(_folder, id + Suffix);

private static GameStateDto ReadDto(string path)
{
var json = File.ReadAllText(path);
var dto = JsonSerializer.Deserialize<GameStateDto>(json, JsonDefaults.Options)
?? throw new JsonException($"{path} is empty.");
return SaveFormat.Upgrade(dto);
}
}

Versioning the save format

You will change GameState after D1 — players arrive in A5, and your own bug fixes will move things around. A version number in every file lets the reader know what it is looking at:

namespace DAL.Json;

public static class SaveFormat
{
public const int Current = 2;

public static GameStateDto Upgrade(GameStateDto dto)
{
if (dto.Version > Current)
throw new NotSupportedException($"Save version {dto.Version} is newer than this program ({Current}).");

if (dto.Version < 2)
{
dto.SavedAtUtc = dto.CreatedAtUtc; // v1 had no SavedAtUtc
dto.Version = 2;
}
return dto;
}
}

Upgrading on read is the friendly option. Refusing old files with a clear message is acceptable in this course; silently loading garbage is not.

Seeding presets on first run

ConfigRepositoryJson above seeds the presets from Presets.All when the folder is empty. Rules: seed only when nothing is there, never overwrite a user's edited preset, and keep the preset list in GameEngine so DAL.EF seeds the same four in A4. If you want presets to be undeletable, add an IsPreset flag to the configuration and let the UI hide the delete option.

Using it from ConsoleUI

using DAL.Json;
using GameEngine;

IConfigRepository configRepository = new ConfigRepositoryJson();
IGameRepository gameRepository = new GameRepositoryJson();
var human = new ConsoleMoveProvider();

// new game from a chosen configuration
var config = configRepository.Get(chosenName);
GameRunner.Run(GameState.New(config), human, human, gameRepository);

// continue a saved game
foreach (var (id, name, savedAt) in gameRepository.List())
{
Console.WriteLine($"{id} {name} {savedAt:g}");
}
GameRunner.Run(gameRepository.Get(selectedId), human, human, gameRepository);

GameRunner.Run from lecture 03.2 gets one extra parameter and one extra line — gameRepository.Save(state) after every successful move. The variables are declared as the interfaces: in A4 the two new lines change to new ConfigRepositoryEf(db) / new GameRepositoryEf(db) and nothing else moves. In Week 8 dependency injection does even the new for you — 08.1 - Repository & DI.

A round-trip test belongs to A3 and runs against a temporary folder:

[Fact]
public void SaveThenGet_ReturnsEqualState()
{
var folder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
IGameRepository repo = new GameRepositoryJson(folder);
var state = GameState.New(Presets.Classic);
new GameBrain(state).MakeMove(5, 3);

repo.Save(state);
var loaded = repo.Get(state.Id);

Assert.Equal(state.Id, loaded.Id);
Assert.Equal(state.Config, loaded.Config); // record equality
Assert.Equal(state.Moves, loaded.Moves);
Assert.Equal(state.Board, loaded.Board); // xUnit compares nested collections element by element
Directory.Delete(folder, recursive: true);
}

Self preparation QA

  1. Why Path.Combine instead of string concatenation? — It inserts the separator that is right for the running OS and handles trailing separators, so the same code works on Windows, macOS and Linux.
  2. Where should the JSON saves live, and how do you find that folder in code? — In the user's home (or application data) directory under an app-specific folder, obtained with Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) — never an absolute path typed into the source.
  3. What does an atomic write protect against? — A crash mid-write leaving a half-written file: writing to a temp file and renaming means the real file is either the old complete version or the new complete version.
  4. What belongs in a saved game and what does not? — Configuration, board, next player, move history, players, timestamps, id and a format version; nothing derived such as status or legal moves.
  5. When is a DTO worth the extra class? — When the domain type contains something the serializer cannot handle (tuples, [,], private state) or when you want the file format to stay stable while the engine changes.
  6. Why are IConfigRepository and IGameRepository defined in GameEngine and not in DAL.Json? — So the UI and engine depend only on the contract, and DAL.Json and DAL.EF can both implement it without the engine referencing either.
  7. What are the agreed semantics of Get and Delete?Get throws KeyNotFoundException for a missing key, Delete is idempotent; both implementations must behave identically, which the A4 contract tests verify.
  8. How does the JSON to EF switch in A4 stay a two-line change? — Consumers hold the interface types; only the two new expressions (later, the DI registrations) name a concrete implementation.