C# Mini Project: A Console Bookstore (List, Add, Search, Save to a File)
The closing project of the C# Fundamentals series: a console Bookstore built with enums, DateOnly, nullable types, TryParse, exception filters and file I/O. Two real bugs caught on the first run (14,00 becoming 1400 and a silently deleted line), their fixes, and the bridge to the OOP series.
Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.
Guess First
The console application we write in this post asks the user for a book's price. This is the function that reads the price. What happens if the user types 14,00 with a comma, as people do in Turkey and much of Europe? Does the function reject the input and ask again, return 14, or something else?
decimal? ReadPrice()
{
while (true)
{
string? text = ReadLine("Price (e.g. 12.50): ");
if (text == null)
{
return null;
}
if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out decimal price) && price > 0)
{
return price;
}
Console.WriteLine("Price must be a positive number like 12.50.");
}
}
What We Are Building
The answer: the function returns 1400. No warning, no exception; a book that costs 14 silently becomes 1400. NumberStyles.Number allows a thousands separator, and in the invariant culture the thousands separator is the comma; so "14,00" is read as "one thousand four hundred". This line comes from the first version of the application we write below. I did not plant the bug on purpose; I caught it the first time I ran the application. In the middle of the post we will see how we noticed it and how we fixed it.
This is the last post of the C# Fundamentals series. We bring together what we have seen so far in a single program: a small Bookstore that lists books, adds new ones, searches by title or author, and saves everything to a file. The project is a single console project with three files: Book.cs holds the data, BookFile.cs the file work, and Program.cs the conversation with the user. The csproj is the same as in the rest of the series: net6.0, LangVersion 10, nullable and implicit usings enabled.
The data file holds one book per line. I separated the fields with |, because commas are very common in book titles ("Love, Rosie", for example), and in a comma-separated file that breaks the line. Before the first run, the file looked like this:
978-0441172719|Dune|Frank Herbert|Fiction|12.99|14|1965-08-01
978-0553380163|A Brief History of Time|Stephen Hawking|Science|18.50|6|1988-04-01
978-0062316097|Sapiens|Yuval Noah Harari|History|22.00|9|2014-09-04
978-0451524935|1984|George Orwell|Fiction|9.99|20|1949-06-08
978-0547928227|The Hobbit|J. R. R. Tolkien|Fantasy|14.00|5|1937-09-21
The "Fantasy" genre on the last line is not defined in the application. I left it there on purpose; we will soon see what happens to an invalid line.
The Data: Book and Genre
namespace Bookstore.App;
public enum Genre
{
Unknown = 0,
Fiction = 1,
Science = 2,
History = 3,
Children = 4
}
public record Book(
string Isbn,
string Title,
string Author,
Genre Genre,
decimal Price,
int Stock,
DateOnly PublishedOn);
Genre follows our advice from the enum post: the value 0 is not a meaningful genre, it is Unknown. The values are given by hand, because if we later decide to store the genre as a number, we do not want the numbers to shift.
Book is a record. We will look at records in detail in the OOP series; for now, think of it as a box that carries a few values under a single name. Each parameter in the parentheses becomes a property: book.Title, book.Price. The price is a decimal because it is money; the publication date is a DateOnly because it has no time of day and no time zone.
Writing and Reading the File: BookFile
using System.Globalization;
namespace Bookstore.App;
public static class BookFile
{
private const char Separator = '|';
private static readonly CultureInfo FileCulture = CultureInfo.InvariantCulture;
public static List<Book> Load(string path, out int skippedLines)
{
List<Book> books = new List<Book>();
skippedLines = 0;
if (!File.Exists(path))
{
return books;
}
foreach (string line in File.ReadLines(path))
{
Book? book = ParseLine(line);
if (book != null)
{
books.Add(book);
}
else
{
skippedLines++;
}
}
return books;
}
public static void Save(string path, List<Book> books)
{
List<string> lines = new List<string>();
foreach (Book book in books)
{
lines.Add(string.Join(Separator,
book.Isbn,
book.Title,
book.Author,
book.Genre.ToString(),
book.Price.ToString(FileCulture),
book.Stock.ToString(FileCulture),
book.PublishedOn.ToString("yyyy-MM-dd", FileCulture)));
}
string tempPath = path + ".tmp";
File.WriteAllLines(tempPath, lines);
File.Move(tempPath, path, overwrite: true);
}
private static Book? ParseLine(string line)
{
string[] parts = line.Split(Separator);
if (parts.Length != 7)
{
return null;
}
if (!Enum.TryParse(parts[3], out Genre genre) || !Enum.IsDefined(genre)
|| !decimal.TryParse(parts[4], NumberStyles.Number, FileCulture, out decimal price)
|| !int.TryParse(parts[5], NumberStyles.Integer, FileCulture, out int stock)
|| !DateOnly.TryParseExact(parts[6], "yyyy-MM-dd", FileCulture, DateTimeStyles.None, out DateOnly publishedOn))
{
return null;
}
return new Book(parts[0], parts[1], parts[2], genre, price, stock, publishedOn);
}
}
Load first checks whether the file exists. If there is no file on the first run, that is not an error, just an empty list. It reads the lines one by one with File.ReadLines and hands each one to ParseLine. When ParseLine sees something unexpected, it returns null instead of throwing; Load counts the broken lines and reports them through skippedLines. Refusing to open the whole shop because of a single broken line would be a bad user experience.
Numbers and dates in the file are always written and read with the invariant culture and in ISO format. That is the rule from the post on dates and times: data that travels between machines must not depend on culture. When reading the genre, we use TryParse together with Enum.IsDefined; an undefined name such as "Fantasy" or a number such as "42" cannot get through.
Pay attention to two lines in Save. We first write the new content to a temporary file, then put it in place of the real file with File.Move(..., overwrite: true). Had we written straight over books.txt and had the program crashed at that very moment, we would be left with a half-written file. With this approach, the worst case is a leftover .tmp file; the old data stays intact.
The Main Loop and the Menu
Since Program.cs is long, I show it in four parts. Put the parts into the same file in order and you get the complete program. The first part is the program's skeleton:
using System.Globalization;
using Bookstore.App;
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
string dataPath = args.Length > 0 ? args[0] : "books.txt";
List<Book> books = BookFile.Load(dataPath, out int skipped);
Console.WriteLine($"Loaded {books.Count} book(s) from {Path.GetFileName(dataPath)}.");
if (skipped > 0)
{
Console.WriteLine($"Warning: {skipped} invalid line(s) were skipped.");
}
bool running = true;
while (running)
{
Console.WriteLine();
Console.WriteLine("1) List 2) Add 3) Search 4) Save and exit");
string? choice = ReadLine("Choice: ");
switch (choice?.Trim())
{
case "1":
PrintBooks(books);
break;
case "2":
AddBook();
break;
case "3":
Search();
break;
case "4":
case null:
running = false;
break;
default:
Console.WriteLine("Please choose 1, 2, 3 or 4.");
break;
}
}
try
{
BookFile.Save(dataPath, books);
Console.WriteLine($"Saved {books.Count} book(s). Bye!");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Console.WriteLine($"Could not save the books: {ex.Message}");
}
Since the interface text is in English, numbers and dates on screen are formatted with the en-US culture too; I set this explicitly on the first line. The file path can be passed on the command line; otherwise books.txt in the working directory is used. Remember the warning about relative paths from the post on files and the console: the file is looked up in whatever folder you run the program from.
The menu is a while loop and a switch. The case null arm matters: when input ends, that is, when the user presses Ctrl+D or the lines coming through a pipe run out, ReadLine returns null, and the program saves and exits instead of looping forever. The saving part uses the when filter from the exception handling post: we catch only the two file-related exceptions and do not swallow an error we did not expect.
Listing and Searching
void PrintBooks(List<Book> list)
{
if (list.Count == 0)
{
Console.WriteLine("No books.");
return;
}
foreach (Book book in list)
{
Console.WriteLine($"{book.Isbn,-15} {book.Title,-24} {book.Author,-18} {book.Genre,-9} {book.Price,7:0.00} {book.Stock,3} {book.PublishedOn:yyyy}");
}
}
void Search()
{
string? term = ReadLine("Search title or author: ");
if (string.IsNullOrWhiteSpace(term))
{
return;
}
List<Book> found = new List<Book>();
foreach (Book book in books)
{
if (book.Title.Contains(term.Trim(), StringComparison.OrdinalIgnoreCase)
|| book.Author.Contains(term.Trim(), StringComparison.OrdinalIgnoreCase))
{
found.Add(book);
}
}
Console.WriteLine($"{found.Count} result(s):");
PrintBooks(found);
}
PrintBooks draws a plain table with the alignment feature of string interpolation: {book.Title,-24} is 24 characters, left-aligned; {book.Price,7:0.00} is 7 characters, right-aligned, with two decimals. We use the same function for both the full list and the search results.
The search finds the text in the title or the author name regardless of case. Choosing StringComparison.OrdinalIgnoreCase is deliberate: it does a culture-independent comparison and avoids the Turkish "I/ı/İ" trap from the post on strings. We wrote a plain foreach to collect the matches; in the LINQ series we will see the one-line version.
Adding a Book and Validating Input
void AddBook()
{
string? isbn = ReadLine("ISBN-13: ")?.Trim();
if (isbn == null || !IsValidIsbn(isbn))
{
Console.WriteLine("Invalid ISBN.");
return;
}
foreach (Book existing in books)
{
if (existing.Isbn == isbn)
{
Console.WriteLine($"'{existing.Title}' already has this ISBN.");
return;
}
}
string? title = ReadLine("Title: ")?.Trim();
string? author = ReadLine("Author: ")?.Trim();
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(author)
|| title.Contains('|') || author.Contains('|'))
{
Console.WriteLine("Title and author are required and cannot contain '|'.");
return;
}
Genre? genre = ReadGenre();
decimal? price = ReadPrice();
int? stock = ReadStock();
DateOnly? publishedOn = ReadDate();
if (genre == null || price == null || stock == null || publishedOn == null)
{
Console.WriteLine("Input ended, the book was not added.");
return;
}
books.Add(new Book(isbn, title, author, genre.Value, price.Value, stock.Value, publishedOn.Value));
Console.WriteLine($"Added '{title}'.");
}
AddBook is written with guard clauses from top to bottom: if something is wrong, print the message and return immediately. We validate the ISBN not as free text but with the ISBN-13 check from the post on organizing code, and we do not add the book if the same ISBN is already in the list. We also reject a | in the title or author name, because that character is our file's separator. Letting user input break the file format is the most classic way to get silent data corruption.
Genre, price, stock and date come from separate functions, and all of them return nullable values: Genre?, decimal?, int?, DateOnly?. Null means "input has ended". As we saw in the nullable post, once we are sure none of the four values is null, we reach the actual values with .Value.
The last part is the helper functions that read input. You saw ReadPrice at the start of the post; the rest follow the same pattern:
Genre? ReadGenre()
{
while (true)
{
string? text = ReadLine("Genre (Fiction, Science, History, Children): ");
if (text == null)
{
return null;
}
if (!int.TryParse(text, out _)
&& Enum.TryParse(text, ignoreCase: true, out Genre genre)
&& Enum.IsDefined(genre) && genre != Genre.Unknown)
{
return genre;
}
Console.WriteLine("Unknown genre, try again.");
}
}
int? ReadStock()
{
while (true)
{
string? text = ReadLine("Stock: ");
if (text == null)
{
return null;
}
if (int.TryParse(text, out int stock) && stock >= 0)
{
return stock;
}
Console.WriteLine("Stock must be zero or a positive whole number.");
}
}
DateOnly? ReadDate()
{
while (true)
{
string? text = ReadLine("Published on (yyyy-MM-dd): ");
if (text == null)
{
return null;
}
if (DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly date)
&& date <= DateOnly.FromDateTime(DateTime.Today))
{
return date;
}
Console.WriteLine("Use the yyyy-MM-dd format and a date that is not in the future.");
}
}
string? ReadLine(string prompt)
{
Console.Write(prompt);
string? line = Console.ReadLine();
if (Console.IsInputRedirected)
{
Console.WriteLine(line);
}
return line;
}
static bool IsValidIsbn(string isbn)
{
string digits = isbn.Replace("-", "");
if (digits.Length != 13 || !digits.All(char.IsDigit))
{
return false;
}
int sum = 0;
for (int i = 0; i < 12; i++)
{
int digit = digits[i] - '0';
sum += i % 2 == 0 ? digit : digit * 3;
}
int check = (10 - sum % 10) % 10;
return check == digits[12] - '0';
}
Each helper keeps asking until a valid value arrives or input ends. ReadGenre first checks that the input is not a number, to close the number trap from the enum post; a user who types "1" has not chosen Fiction. ReadDate does not accept a publication date in the future. ReadLine is a small convenience: if input comes through a pipe (Console.IsInputRedirected), it also prints the line it read. That way, when you run the program with a test input, the session reads as if it had been typed on the keyboard.
The First Run and Two Bugs
I ran the application with an input file: list, search for "history", try to add an existing ISBN, add The Hobbit, list again, save. Apart from the NETSDK1138 build warning that dotnet run prints at the very top, here is the whole session:
$ dotnet run < input.txt
Loaded 4 book(s) from books.txt.
Warning: 1 invalid line(s) were skipped.
1) List 2) Add 3) Search 4) Save and exit
Choice: 1
978-0441172719 Dune Frank Herbert Fiction 12.99 14 1965
978-0553380163 A Brief History of Time Stephen Hawking Science 18.50 6 1988
978-0062316097 Sapiens Yuval Noah Harari History 22.00 9 2014
978-0451524935 1984 George Orwell Fiction 9.99 20 1949
1) List 2) Add 3) Search 4) Save and exit
Choice: 3
Search title or author: history
1 result(s):
978-0553380163 A Brief History of Time Stephen Hawking Science 18.50 6 1988
1) List 2) Add 3) Search 4) Save and exit
Choice: 2
ISBN-13: 978-0441172719
'Dune' already has this ISBN.
1) List 2) Add 3) Search 4) Save and exit
Choice: 2
ISBN-13: 978-0547928227
Title: The Hobbit
Author: J. R. R. Tolkien
Genre (Fiction, Science, History, Children): Fantasy
Unknown genre, try again.
Genre (Fiction, Science, History, Children): Children
Price (e.g. 12.50): 14,00
Stock: 14.00
Stock must be zero or a positive whole number.
Stock: 5
Published on (yyyy-MM-dd): 1937-09-21
Added 'The Hobbit'.
1) List 2) Add 3) Search 4) Save and exit
Choice: 1
978-0441172719 Dune Frank Herbert Fiction 12.99 14 1965
978-0553380163 A Brief History of Time Stephen Hawking Science 18.50 6 1988
978-0062316097 Sapiens Yuval Noah Harari History 22.00 9 2014
978-0451524935 1984 George Orwell Fiction 9.99 20 1949
978-0547928227 The Hobbit J. R. R. Tolkien Children 1400.00 5 1937
1) List 2) Add 3) Search 4) Save and exit
Choice: 4
Saved 5 book(s). Bye!
At first glance everything is fine: the invalid line was skipped with a warning, the search found the right book, the duplicate ISBN was rejected, and the "Fantasy" genre was not accepted. But in the last list, The Hobbit shows up as 1400.00. Looking at the input, the reason is clear: "14,00" was typed for the price and accepted. The next line, "14.00", went to the stock question and was rejected there. Had you put a breakpoint on the return line of ReadPrice, as in the debugging post, you would have seen in the Watch window that price is 1400 at the very first stop.
The fix is a single line. We use a style that does not allow a thousands separator:
if (decimal.TryParse(text, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out decimal price) && price > 0)
I made the same change to the decimal.TryParse in BookFile.ParseLine; there should be no thousands separator in the file either.
The second bug does not show on screen; it hides in the file. This is the saved file:
978-0441172719|Dune|Frank Herbert|Fiction|12.99|14|1965-08-01
978-0553380163|A Brief History of Time|Stephen Hawking|Science|18.50|6|1988-04-01
978-0062316097|Sapiens|Yuval Noah Harari|History|22.00|9|2014-09-04
978-0451524935|1984|George Orwell|Fiction|9.99|20|1949-06-08
978-0547928227|The Hobbit|J. R. R. Tolkien|Children|1400|5|1937-09-21
The original The Hobbit line with the "Fantasy" genre is gone. The application skipped it on load and wrote over the file on save. So a line the user could have fixed by hand was silently deleted, accompanied only by a warning message. Skipping broken data is a good decision; destroying it is not. The simplest fix is to back up the original file before saving if any lines were skipped:
try
{
if (skipped > 0 && File.Exists(dataPath))
{
File.Copy(dataPath, dataPath + ".bak", overwrite: true);
Console.WriteLine($"Backed up the original file to {Path.GetFileName(dataPath)}.bak.");
}
BookFile.Save(dataPath, books);
// the rest of the save stays the same
After the two fixes, I ran it again with the same input. The relevant parts of the session:
Price (e.g. 12.50): 14,00
Price must be a positive number like 12.50.
Price (e.g. 12.50): 14.00
...
978-0451524935 1984 George Orwell Fiction 9.99 20 1949
978-0547928227 The Hobbit J. R. R. Tolkien Children 14.00 5 1937
1) List 2) Add 3) Search 4) Save and exit
Choice: 4
Backed up the original file to books.txt.bak.
Saved 5 book(s). Bye!
"14,00" is now rejected, the price is saved as 14.00, and the original line is kept in books.txt.bak. Both bugs were of a kind the compiler cannot see; we found both by running the program with real input and reading the output carefully.
Common Pitfalls
Not knowing what a style and a culture accept when parsing numbers and dates. NumberStyles.Number also accepts a thousands separator. Tell the user clearly what you expect ("like 12.50") and pick a style that accepts exactly that.
Skipping data you cannot read and then writing over the file. Skipping is a temporary decision; deleting is permanent. Take a backup, write the broken lines back unchanged, or refuse to save.
Writing directly over a file. If the write stops halfway, both the new and the old data are gone. Write to a temporary file, then move it.
Letting user input leak into the file format. The separator character, line breaks, quotes: any of them can break the format. Validate the input, or move the format to a structure that withstands them (JSON, later on).
You have probably noticed that even in this small program everything in the top-level code touches everything else: every function reaches the books list, the screen and the business rules are intertwined, and a Book cannot protect itself from being created with a negative price. This code is also hard to test. These are not bugs; they are the limits of the basic tools.
A Small Experiment
★☆☆ Add "5) Delete" to the menu: take an ISBN from the user, delete the book if it is in the list, otherwise print "Not found."
★★☆ Add "6) Sell": take an ISBN and a quantity, decrease the stock if there is enough, otherwise refuse the sale. Since a record cannot be changed, you will need the books[i] = book with { Stock = book.Stock - quantity }; syntax; we will look at with in detail in the OOP series.
★★★ Instead of taking a backup, keep the broken lines: have Load also return the invalid lines as a separate List<string>, and have Save write them back unchanged at the end of the file. Then check that the "Fantasy" line is still in place after several saves.
This is where we finish the C# Fundamentals series. You now have all the basic tools, from variables to files; the shortcomings we listed in the last section are the subject of the next step. In the Object-Oriented Programming with C# series we start with classes and objects, and learn to split this Bookstore into pieces that keep data and behavior together, validate themselves and can be tested.