I'll provide an answer, and an implementation I used using EF, Unit of Work, and Repository Pattern.
Answer
From the documentation:
If you want to rollback a transaction, you should not call the Complete method within the transaction scope. For example, you can throw an exception within the scope. The transaction in which it participates in will be rolled back.
Take a look to the examples they share in the links. Those are highly illustrative (I'll copy one of the examples down below, just in case).
Implementation Example
My Implementation
This is what I had to save into the DB, from an MVC project:

I faced this problem as follows, implementing the method:
void SaveTheThing(tracker, driver, manager, destination, transportation);
Read the comments:
// Create the TransactionScope to execute the commands, guaranteeing
// that both commands can commit or roll back as a single unit of work.
using (TransactionScope scope = new TransactionScope())
{
// Step 1
int driverId = SavePerson(driver);
int destinationId = SaveDestination(destination);
// Step 2
transportation.id_person_driver = driverId;
transportation.id_destination = destinationId;
int transportationId = SaveTransportation(transportation);
// Step 3
int managerId = SavePerson(manager);
// Step 4
tracker.id_person_manager = managerId;
tracker.id_transportation = transportationId;
SaveTracker(tracker);
// Step 5
// The Complete method commits explicitly the transaction.
// If an exception has been thrown, then Complete is not
// called and the transaction is rolled back.
/* If I add an exception here, the transaction will roll-back */
throw new Exception("Roll it back!");
// This code becomes unreachable until you delete the Exception
scope.Complete(); // If we get here things are looking good.
_unitOfWork.Save(); // If we get here it is save to accept all changes.
}
// Implicitly rolls back the transaction to the DB if something goes wrong.
// Scope is disposed. Entity(ies) are not committed.
Inside, SaveWhatever(whatever)
, there's methods that use SaveChanges()
and other DbContext and DbSet related methods.
And in controller (this is an MVC project):
try
{
SaveTheThing(tracker, driver, manager, destination, transportation);
}
catch (TransactionAbortedException taEx)
{
// If something wrong happens while committing the transaction,
// it will rollback and throw this exception.
Console.WriteLine(taEx.Message);
return View("Edit", vm);
}
catch (Exception ex)
{
// The thrown Exception is catched here.
Console.WriteLine(ex.Message);
return View("Edit", vm);
}
Keep in mind that this approach presents some issues that I'm still to investigate: There's some persistence in EF or in my repositories, that remains while the connections is open (I'm working on it, I'll update when I fix it). But yes, the DB does not receive the data and the roll-back is actually made.
Readings
// This function takes arguments for 2 connection strings and commands to create a transaction
// involving two SQL Servers. It returns a value > 0 if the transaction is committed, 0 if the
// transaction is rolled back. To test this code, you can connect to two different databases
// on the same server by altering the connection string, or to another 3rd party RDBMS by
// altering the code in the connection2 code block.
static public int CreateTransactionScope(
string connectString1, string connectString2,
string commandText1, string commandText2)
{
// Initialize the return value to zero and create a StringWriter to display results.
int returnValue = 0;
System.IO.StringWriter writer = new System.IO.StringWriter();
try
{
// Create the TransactionScope to execute the commands, guaranteeing
// that both commands can commit or roll back as a single unit of work.
using (TransactionScope scope = new TransactionScope())
{
using (SqlConnection connection1 = new SqlConnection(connectString1))
{
// Opening the connection automatically enlists it in the
// TransactionScope as a lightweight transaction.
connection1.Open();
// Create the SqlCommand object and execute the first command.
SqlCommand command1 = new SqlCommand(commandText1, connection1);
returnValue = command1.ExecuteNonQuery();
writer.WriteLine("Rows to be affected by command1: {0}", returnValue);
// If you get here, this means that command1 succeeded. By nesting
// the using block for connection2 inside that of connection1, you
// conserve server and network resources as connection2 is opened
// only when there is a chance that the transaction can commit.
using (SqlConnection connection2 = new SqlConnection(connectString2))
{
// The transaction is escalated to a full distributed
// transaction when connection2 is opened.
connection2.Open();
// Execute the second command in the second database.
returnValue = 0;
SqlCommand command2 = new SqlCommand(commandText2, connection2);
returnValue = command2.ExecuteNonQuery();
writer.WriteLine("Rows to be affected by command2: {0}", returnValue);
}
}
// The Complete method commits the transaction. If an exception has been thrown,
// Complete is not called and the transaction is rolled back.
scope.Complete();
}
}
catch (TransactionAbortedException ex)
{
writer.WriteLine("TransactionAbortedException Message: {0}", ex.Message);
}
// Display messages.
Console.WriteLine(writer.ToString());
return returnValue;
}